diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ef7d0fcc..bdc96713 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -99,7 +99,7 @@ jobs: - name: Restore run: dotnet restore "${{ env.PROJECT_PATH }}" - - name: Build + - name: 🔨 Build run: >- dotnet build "${{ env.PROJECT_PATH }}" --configuration "${{ env.CONFIGURATION }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3c32f01..a20b2882 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -128,7 +128,7 @@ jobs: - name: Install dependencies run: dotnet restore "${{ env.PROJECT_PATH }}" - - name: Create the package + - name: 📦 Create the package run: >- dotnet pack "${{ env.PROJECT_PATH }}" --output ./artifacts @@ -140,7 +140,7 @@ jobs: -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg - - name: Create the package - Signed + - name: 📦 Create the package - Signed run: >- dotnet pack "${{ env.PROJECT_PATH }}" --output ./artifacts diff --git a/Directory.Build.props b/Directory.Build.props index 3d16a055..f2b9a699 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,5 +5,17 @@ strict true + + + true + true + true + true + + + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..d5faa965 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + |net20|net40|net45|net451|net452|net46| + |net20|net40|net45|net451|net452|net46|net461| + |net45|net451|net452|net46| + |net45|net451|net452|net46|net461| + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5a788f19..4cb6caf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: redmine: ports: - '8089:3000' - image: 'redmine:5.1.1-alpine' - container_name: 'redmine-web' + image: 'redmine:6.0.5-alpine' + container_name: 'redmine-web605' depends_on: - db-postgres # healthcheck: @@ -32,8 +32,8 @@ services: POSTGRES_DB: redmine POSTGRES_USER: redmine-usr POSTGRES_PASSWORD: redmine-pswd - container_name: 'redmine-db' - image: 'postgres:16-alpine' + container_name: 'redmine-db175' + image: 'postgres:17.5-alpine' healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 20s diff --git a/global.json b/global.json index 6223f1b6..1f044567 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.101", + "version": "9.0.203", "allowPrerelease": false, "rollForward": "latestMajor" } diff --git a/redmine-net-api.sln b/redmine-net-api.sln index 86cc3bbe..6e9f665f 100644 --- a/redmine-net-api.sln +++ b/redmine-net-api.sln @@ -38,6 +38,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Props", "Props", "{707B6A3F releasenotes.props = releasenotes.props signing.props = signing.props version.props = version.props + Directory.Packages.props = Directory.Packages.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{1D340EEB-C535-45D4-80D7-ADD4434D7B77}" diff --git a/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs index a037a60c..c1d59744 100644 --- a/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs +++ b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs @@ -15,6 +15,7 @@ limitations under the License. */ using System.Net; +using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Authentication; @@ -24,7 +25,7 @@ namespace Redmine.Net.Api.Authentication; public sealed class RedmineApiKeyAuthentication: IRedmineAuthentication { /// - public string AuthenticationType => "X-Redmine-API-Key"; + public string AuthenticationType { get; } = RedmineAuthenticationType.ApiKey.ToText(); /// public string Token { get; init; } diff --git a/src/redmine-net-api/Authentication/RedmineAuthenticationType.cs b/src/redmine-net-api/Authentication/RedmineAuthenticationType.cs new file mode 100644 index 00000000..22c38cd2 --- /dev/null +++ b/src/redmine-net-api/Authentication/RedmineAuthenticationType.cs @@ -0,0 +1,8 @@ +namespace Redmine.Net.Api.Authentication; + +internal enum RedmineAuthenticationType +{ + NoAuthentication, + Basic, + ApiKey +} \ No newline at end of file diff --git a/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs b/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs index e78aa653..810da00a 100644 --- a/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs +++ b/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs @@ -18,6 +18,7 @@ limitations under the License. using System.Net; using System.Text; using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Authentication { @@ -27,7 +28,7 @@ namespace Redmine.Net.Api.Authentication public sealed class RedmineBasicAuthentication: IRedmineAuthentication { /// - public string AuthenticationType => "Basic"; + public string AuthenticationType { get; } = RedmineAuthenticationType.Basic.ToText(); /// public string Token { get; init; } @@ -45,7 +46,7 @@ public RedmineBasicAuthentication(string username, string password) if (username == null) throw new RedmineException(nameof(username)); if (password == null) throw new RedmineException(nameof(password)); - Token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + Token = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))}"; } } } \ No newline at end of file diff --git a/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs b/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs index 4f2ed673..d8518828 100644 --- a/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs +++ b/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs @@ -15,6 +15,7 @@ limitations under the License. */ using System.Net; +using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Authentication; @@ -24,7 +25,7 @@ namespace Redmine.Net.Api.Authentication; public sealed class RedmineNoAuthentication: IRedmineAuthentication { /// - public string AuthenticationType => "NoAuth"; + public string AuthenticationType { get; } = RedmineAuthenticationType.NoAuthentication.ToText(); /// public string Token { get; init; } diff --git a/src/redmine-net-api/Common/AType.cs b/src/redmine-net-api/Common/AType.cs new file mode 100644 index 00000000..80d0493c --- /dev/null +++ b/src/redmine-net-api/Common/AType.cs @@ -0,0 +1,12 @@ +using System; + +namespace Redmine.Net.Api.Common; + +internal readonly struct A{ + public static A Is => default; +#pragma warning disable CS0184 // 'is' expression's given expression is never of the provided type + public static bool IsEqual() => Is is A; +#pragma warning restore CS0184 // 'is' expression's given expression is never of the provided type + public static Type Value => typeof(T); + +} \ No newline at end of file diff --git a/src/redmine-net-api/Common/ArgumentVerifier.cs b/src/redmine-net-api/Common/ArgumentVerifier.cs new file mode 100644 index 00000000..c4e7ede7 --- /dev/null +++ b/src/redmine-net-api/Common/ArgumentVerifier.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Redmine.Net.Api.Common; + +/// +/// A utility class to perform argument validations. +/// +internal static class ArgumentVerifier +{ + /// + /// Throws ArgumentNullException if the argument is null. + /// + /// Argument value to check. + /// Name of Argument. + public static void ThrowIfNull([NotNull] object value, string name) + { + if (value == null) + { + throw new ArgumentNullException(name); + } + } + + /// + /// Validates string and throws: + /// ArgumentNullException if the argument is null. + /// ArgumentException if the argument is empty. + /// + /// Argument value to check. + /// Name of Argument. + public static void ThrowIfNullOrEmpty([NotNull] string value, string name) + { + if (value == null) + { + throw new ArgumentNullException(name); + } + + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("The value cannot be null or empty", name); + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Types/IValue.cs b/src/redmine-net-api/Common/IValue.cs similarity index 95% rename from src/redmine-net-api/Types/IValue.cs rename to src/redmine-net-api/Common/IValue.cs index bbfe3a77..d95d24eb 100755 --- a/src/redmine-net-api/Types/IValue.cs +++ b/src/redmine-net-api/Common/IValue.cs @@ -14,7 +14,7 @@ You may obtain a copy of the License at limitations under the License. */ -namespace Redmine.Net.Api.Types +namespace Redmine.Net.Api.Common { /// /// diff --git a/src/redmine-net-api/Types/PagedResults.cs b/src/redmine-net-api/Common/PagedResults.cs similarity index 91% rename from src/redmine-net-api/Types/PagedResults.cs rename to src/redmine-net-api/Common/PagedResults.cs index eab0c680..af1e82fd 100644 --- a/src/redmine-net-api/Types/PagedResults.cs +++ b/src/redmine-net-api/Common/PagedResults.cs @@ -16,7 +16,7 @@ limitations under the License. using System.Collections.Generic; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Common { /// /// @@ -30,7 +30,7 @@ public sealed class PagedResults where TOut: class /// /// /// - public PagedResults(IEnumerable items, int total, int offset, int pageSize) + public PagedResults(List items, int total, int offset, int pageSize) { Items = items; TotalItems = total; @@ -75,6 +75,6 @@ public PagedResults(IEnumerable items, int total, int offset, int pageSize /// /// /// - public IEnumerable Items { get; } + public List Items { get; } } } \ No newline at end of file diff --git a/src/redmine-net-api/Exceptions/RedmineException.cs b/src/redmine-net-api/Exceptions/RedmineException.cs index db791454..ee2cc07a 100644 --- a/src/redmine-net-api/Exceptions/RedmineException.cs +++ b/src/redmine-net-api/Exceptions/RedmineException.cs @@ -15,6 +15,7 @@ limitations under the License. */ using System; +using System.Diagnostics; using System.Globalization; using System.Runtime.Serialization; @@ -24,6 +25,7 @@ namespace Redmine.Net.Api.Exceptions /// Thrown in case something went wrong in Redmine /// /// + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [Serializable] public class RedmineException : Exception { @@ -85,5 +87,7 @@ protected RedmineException(SerializationInfo serializationInfo, StreamingContext } #endif + + private string DebuggerDisplay => $"[{Message}]"; } } \ No newline at end of file diff --git a/src/redmine-net-api/Exceptions/RedmineSerializationException.cs b/src/redmine-net-api/Exceptions/RedmineSerializationException.cs new file mode 100644 index 00000000..0f6b779f --- /dev/null +++ b/src/redmine-net-api/Exceptions/RedmineSerializationException.cs @@ -0,0 +1,48 @@ +using System; + +namespace Redmine.Net.Api.Exceptions; + +/// +/// Represents an error that occurs during JSON serialization or deserialization. +/// +public class RedmineSerializationException : RedmineException +{ + /// + /// Initializes a new instance of the class. + /// + public RedmineSerializationException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public RedmineSerializationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + /// /// The name of the parameter that caused the exception. + public RedmineSerializationException(string message, string paramName) : base(message) + { + ParamName = paramName; + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public RedmineSerializationException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Gets the name of the parameter that caused the current exception. + /// + public string ParamName { get; } +} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/CollectionExtensions.cs b/src/redmine-net-api/Extensions/CollectionExtensions.cs deleted file mode 100755 index 6b56e79c..00000000 --- a/src/redmine-net-api/Extensions/CollectionExtensions.cs +++ /dev/null @@ -1,113 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.Text; -using Redmine.Net.Api.Types; - -namespace Redmine.Net.Api.Extensions -{ - /// - /// - /// - public static class CollectionExtensions - { - /// - /// Clones the specified list to clone. - /// - /// - /// The list to clone. - /// - /// - public static IList Clone(this IList listToClone, bool resetId) where T : ICloneable - { - if (listToClone == null) - { - return null; - } - - var clonedList = new List(); - - for (var index = 0; index < listToClone.Count; index++) - { - var item = listToClone[index]; - clonedList.Add(item.Clone(resetId)); - } - - return clonedList; - } - - /// - /// - /// - /// - /// The list. - /// The list to compare. - /// - public static bool Equals(this IList list, IList listToCompare) where T : class - { - if (list == null || listToCompare == null) - { - return false; - } - - if (list.Count != listToCompare.Count) - { - return false; - } - - var index = 0; - while (index < list.Count && list[index].Equals(listToCompare[index])) - { - index++; - } - - return index == list.Count; - } - - /// - /// - /// - /// - public static string Dump(this IEnumerable collection) where TIn : class - { - if (collection == null) - { - return null; - } - - var sb = new StringBuilder("{"); - - foreach (var item in collection) - { - sb.Append(item).Append(','); - } - - if (sb.Length > 1) - { - sb.Length -= 1; - } - - sb.Append('}'); - - var str = sb.ToString(); - sb.Length = 0; - - return str; - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/EnumExtensions.cs b/src/redmine-net-api/Extensions/EnumExtensions.cs new file mode 100644 index 00000000..60b6499e --- /dev/null +++ b/src/redmine-net-api/Extensions/EnumExtensions.cs @@ -0,0 +1,113 @@ +using System; +using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Types; + +namespace Redmine.Net.Api.Extensions; + +/// +/// Provides extension methods for enumerations used in the Redmine.Net.Api.Types namespace. +/// +public static class EnumExtensions +{ + /// + /// Converts the specified enumeration value to its lowercase invariant string representation. + /// + /// The enumeration value to be converted. + /// A string representation of the IssueRelationType enumeration value in a lowercase, or "undefined" if the value does not match a defined case. + public static string ToLowerName(this IssueRelationType @enum) + { + return @enum switch + { + IssueRelationType.Relates => "relates", + IssueRelationType.Duplicates => "duplicates", + IssueRelationType.Duplicated => "duplicated", + IssueRelationType.Blocks => "blocks", + IssueRelationType.Blocked => "blocked", + IssueRelationType.Precedes => "precedes", + IssueRelationType.Follows => "follows", + IssueRelationType.CopiedTo => "copied_to", + IssueRelationType.CopiedFrom => "copied_from", + _ => "undefined" + }; + } + + /// + /// Converts the specified VersionSharing enumeration value to its lowercase invariant string representation. + /// + /// The VersionSharing enumeration value to be converted. + /// A string representation of the VersionSharing enumeration value in a lowercase, or "undefined" if the value does not match a valid case. + public static string ToLowerName(this VersionSharing @enum) + { + return @enum switch + { + VersionSharing.Unknown => "unknown", + VersionSharing.None => "none", + VersionSharing.Descendants => "descendants", + VersionSharing.Hierarchy => "hierarchy", + VersionSharing.Tree => "tree", + VersionSharing.System => "system", + _ => "undefined" + }; + } + + /// + /// Converts the specified enumeration value to its lowercase invariant string representation. + /// + /// The enumeration value to be converted. + /// A lowercase string representation of the enumeration value, or "undefined" if the value does not match a defined case. + public static string ToLowerName(this VersionStatus @enum) + { + return @enum switch + { + VersionStatus.None => "none", + VersionStatus.Open => "open", + VersionStatus.Closed => "closed", + VersionStatus.Locked => "locked", + _ => "undefined" + }; + } + + /// + /// Converts the specified ProjectStatus enumeration value to its lowercase invariant string representation. + /// + /// The ProjectStatus enumeration value to be converted. + /// A string representation of the ProjectStatus enumeration value in a lowercase, or "undefined" if the value does not match a defined case. + public static string ToLowerName(this ProjectStatus @enum) + { + return @enum switch + { + ProjectStatus.None => "none", + ProjectStatus.Active => "active", + ProjectStatus.Archived => "archived", + ProjectStatus.Closed => "closed", + _ => "undefined" + }; + } + + /// + /// Converts the specified enumeration value to its lowercase invariant string representation. + /// + /// The enumeration value to be converted. + /// A string representation of the UserStatus enumeration value in a lowercase, or "undefined" if the value does not match a defined case. + public static string ToLowerName(this UserStatus @enum) + { + return @enum switch + { + UserStatus.StatusActive => "status_active", + UserStatus.StatusLocked => "status_locked", + UserStatus.StatusRegistered => "status_registered", + _ => "undefined" + }; + } + + internal static string ToText(this RedmineAuthenticationType @enum) + { + return @enum switch + { + RedmineAuthenticationType.NoAuthentication => "NoAuth", + RedmineAuthenticationType.Basic => "Basic", + RedmineAuthenticationType.ApiKey => "ApiKey", + _ => "undefined" + }; + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/IEnumerableExtensions.cs b/src/redmine-net-api/Extensions/IEnumerableExtensions.cs new file mode 100644 index 00000000..98d9b355 --- /dev/null +++ b/src/redmine-net-api/Extensions/IEnumerableExtensions.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Redmine.Net.Api.Common; + +namespace Redmine.Net.Api.Extensions; + +/// +/// Provides extension methods for IEnumerable types. +/// +public static class IEnumerableExtensions +{ + /// + /// Converts a collection of objects into a string representation with each item separated by a comma + /// and enclosed within curly braces. + /// + /// The type of items in the collection. The type must be a reference type. + /// The collection of items to convert to a string representation. + /// + /// Returns a string containing all the items from the collection, separated by commas and + /// enclosed within curly braces. Returns null if the collection is null. + /// + internal static string Dump(this IEnumerable collection) where TIn : class + { + if (collection == null) + { + return null; + } + + var sb = new StringBuilder("{"); + + foreach (var item in collection) + { + sb.Append(item).Append(','); + } + + if (sb.Length > 1) + { + sb.Length -= 1; + } + + sb.Append('}'); + + var str = sb.ToString(); + sb.Length = 0; + + return str; + } + + /// + /// Returns the index of the first item in the sequence that satisfies the predicate. If no item satisfies the predicate, -1 is returned. + /// + /// The type of objects in the . + /// in which to search. + /// Function performed to check whether an item satisfies the condition. + /// Return the zero-based index of the first occurrence of an element that satisfies the condition, if found; otherwise, -1. + internal static int IndexOf(this IEnumerable source, Func predicate) + { + ArgumentVerifier.ThrowIfNull(predicate, nameof(predicate)); + + var index = 0; + + foreach (var item in source) + { + if (predicate(item)) + { + return index; + } + + index++; + } + + return -1; + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/IdentifiableNameExtensions.cs b/src/redmine-net-api/Extensions/IdentifiableNameExtensions.cs new file mode 100644 index 00000000..beed972a --- /dev/null +++ b/src/redmine-net-api/Extensions/IdentifiableNameExtensions.cs @@ -0,0 +1,82 @@ +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Types; + +namespace Redmine.Net.Api.Extensions +{ + /// + /// + /// + public static class IdentifiableNameExtensions + { + /// + /// Converts an object of type into an object. + /// + /// The type of the entity to convert. Expected to be one of the supported Redmine entity types. + /// The entity object to be converted into an . + /// An object populated with the identifier and name of the specified entity, or null if the entity type is not supported. + public static IdentifiableName ToIdentifiableName(this T entity) where T : class + { + return entity switch + { + CustomField customField => IdentifiableName.Create(customField.Id, customField.Name), + CustomFieldRole customFieldRole => IdentifiableName.Create(customFieldRole.Id, customFieldRole.Name), + DocumentCategory documentCategory => IdentifiableName.Create(documentCategory.Id, documentCategory.Name), + Group group => IdentifiableName.Create(group.Id, group.Name), + GroupUser groupUser => IdentifiableName.Create(groupUser.Id, groupUser.Name), + Issue issue => new IdentifiableName(issue.Id, issue.Subject), + IssueAllowedStatus issueAllowedStatus => IdentifiableName.Create(issueAllowedStatus.Id, issueAllowedStatus.Name), + IssueCustomField issueCustomField => IdentifiableName.Create(issueCustomField.Id, issueCustomField.Name), + IssuePriority issuePriority => IdentifiableName.Create(issuePriority.Id, issuePriority.Name), + IssueStatus issueStatus => IdentifiableName.Create(issueStatus.Id, issueStatus.Name), + MembershipRole membershipRole => IdentifiableName.Create(membershipRole.Id, membershipRole.Name), + MyAccountCustomField myAccountCustomField => IdentifiableName.Create(myAccountCustomField.Id, myAccountCustomField.Name), + Project project => IdentifiableName.Create(project.Id, project.Name), + ProjectEnabledModule projectEnabledModule => IdentifiableName.Create(projectEnabledModule.Id, projectEnabledModule.Name), + ProjectIssueCategory projectIssueCategory => IdentifiableName.Create(projectIssueCategory.Id, projectIssueCategory.Name), + ProjectTimeEntryActivity projectTimeEntryActivity => IdentifiableName.Create(projectTimeEntryActivity.Id, projectTimeEntryActivity.Name), + ProjectTracker projectTracker => IdentifiableName.Create(projectTracker.Id, projectTracker.Name), + Query query => IdentifiableName.Create(query.Id, query.Name), + Role role => IdentifiableName.Create(role.Id, role.Name), + TimeEntryActivity timeEntryActivity => IdentifiableName.Create(timeEntryActivity.Id, timeEntryActivity.Name), + Tracker tracker => IdentifiableName.Create(tracker.Id, tracker.Name), + UserGroup userGroup => IdentifiableName.Create(userGroup.Id, userGroup.Name), + Version version => IdentifiableName.Create(version.Id, version.Name), + Watcher watcher => IdentifiableName.Create(watcher.Id, watcher.Name), + _ => null + }; + } + + + /// + /// Converts an integer value to an object. + /// + /// An integer value representing the identifier. Must be greater than zero. + /// An object with the specified identifier and a null name. + /// Thrown when the given value is less than or equal to zero. + public static IdentifiableName ToIdentifier(this int val) + { + if (val <= 0) + { + throw new RedmineException(nameof(val), "Value must be greater than zero"); + } + + return new IdentifiableName(val, null); + } + + /// + /// Converts an integer value into an object. + /// + /// The integer value representing the ID of an issue status. + /// An object initialized with the specified identifier. + /// Thrown when the specified value is less than or equal to zero. + public static IssueStatus ToIssueStatusIdentifier(this int val) + { + if (val <= 0) + { + throw new RedmineException(nameof(val), "Value must be greater than zero"); + } + + return new IssueStatus(val, null); + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/ListExtensions.cs b/src/redmine-net-api/Extensions/ListExtensions.cs new file mode 100755 index 00000000..48ef0705 --- /dev/null +++ b/src/redmine-net-api/Extensions/ListExtensions.cs @@ -0,0 +1,129 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System.Collections.Generic; + +namespace Redmine.Net.Api.Extensions +{ + /// + /// Provides extension methods for operations on lists. + /// + public static class ListExtensions + { + /// + /// Creates a deep clone of the specified list. + /// + /// The type of elements in the list. Must implement . + /// The list to be cloned. + /// Specifies whether to reset the ID for each cloned item. + /// A new list containing cloned copies of the elements from the original list. Returns null if the original list is null. + public static IList Clone(this IList listToClone, bool resetId) where T : ICloneable + { + if (listToClone == null) + { + return null; + } + + var clonedList = new List(listToClone.Count); + + foreach (var item in listToClone) + { + clonedList.Add(item.Clone(resetId)); + } + + return clonedList; + } + + /// + /// Creates a deep clone of the specified list. + /// + /// The type of elements in the list. Must implement . + /// The list to be cloned. + /// Specifies whether to reset the ID for each cloned item. + /// A new list containing cloned copies of the elements from the original list. Returns null if the original list is null. + public static List Clone(this List listToClone, bool resetId) where T : ICloneable + { + if (listToClone == null) + { + return null; + } + + var clonedList = new List(listToClone.Count); + + foreach (var item in listToClone) + { + clonedList.Add(item.Clone(resetId)); + } + return clonedList; + } + + /// + /// Compares two lists for equality by checking if they contain the same elements in the same order. + /// + /// The type of elements in the lists. Must be a reference type. + /// The first list to be compared. + /// The second list to be compared. + /// True if both lists contain the same elements in the same order; otherwise, false. Returns false if either list is null. + public static bool Equals(this IList list, IList listToCompare) where T : class + { + if (list == null || listToCompare == null) + { + return false; + } + + if (list.Count != listToCompare.Count) + { + return false; + } + + var index = 0; + while (index < list.Count && list[index].Equals(listToCompare[index])) + { + index++; + } + + return index == list.Count; + } + + /// + /// Compares two lists for equality based on their elements. + /// + /// The type of elements in the lists. Must be a reference type. + /// The first list to compare. + /// The second list to compare. + /// True if both lists are non-null, have the same count, and all corresponding elements are equal; otherwise, false. + public static bool Equals(this List list, List listToCompare) where T : class + { + if (list == null || listToCompare == null) + { + return false; + } + + if (list.Count != listToCompare.Count) + { + return false; + } + + var index = 0; + while (index < list.Count && list[index].Equals(listToCompare[index])) + { + index++; + } + + return index == list.Count; + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/LoggerExtensions.cs b/src/redmine-net-api/Extensions/LoggerExtensions.cs deleted file mode 100755 index 9e836a19..00000000 --- a/src/redmine-net-api/Extensions/LoggerExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -/* - Copyright 2011 - 2016 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using Redmine.Net.Api.Logging; - -namespace Redmine.Net.Api.Extensions -{ - /// - /// - public static class LoggerExtensions - { - /// - /// Uses the console log. - /// - /// The redmine manager. - public static void UseConsoleLog(this RedmineManager redmineManager) - { - Logger.UseLogger(new ConsoleLogger()); - } - - /// - /// Uses the color console log. - /// - /// The redmine manager. - public static void UseColorConsoleLog(this RedmineManager redmineManager) - { - Logger.UseLogger(new ColorConsoleLogger()); - } - - /// - /// Uses the trace log. - /// - /// The redmine manager. - public static void UseTraceLog(this RedmineManager redmineManager) - { - Logger.UseLogger(new TraceLogger()); - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/RedmineManagerAsyncExtensions.Obsolete.cs b/src/redmine-net-api/Extensions/RedmineManagerAsyncExtensions.Obsolete.cs deleted file mode 100644 index d2b42862..00000000 --- a/src/redmine-net-api/Extensions/RedmineManagerAsyncExtensions.Obsolete.cs +++ /dev/null @@ -1,350 +0,0 @@ -/* -Copyright 2011 - 2025 Adrian Popescu - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -#if !(NET20) - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Threading; -using System.Threading.Tasks; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Serialization; -using Redmine.Net.Api.Types; - -namespace Redmine.Net.Api.Async -{ - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use RedmineManger async methods instead.")] - public static class RedmineManagerAsyncExtensions - { - /// - /// Gets the current user asynchronous. - /// - /// The redmine manager. - /// The parameters. - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task GetCurrentUserAsync(this RedmineManager redmineManager, NameValueCollection parameters = null, string impersonateUserName = null, CancellationToken cancellationToken = default) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - return await redmineManager.GetCurrentUserAsync(requestOptions, cancellationToken).ConfigureAwait(false); - } - - /// - /// Creates the or update wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// Name of the page. - /// The wiki page. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - - return await redmineManager.CreateWikiPageAsync(projectId, pageName, wikiPage, requestOptions).ConfigureAwait(false); - } - - /// - /// Creates the or update wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// Name of the page. - /// The wiki page. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.UpdateWikiPageAsync(projectId, pageName, wikiPage, requestOptions).ConfigureAwait(false); - } - - /// - /// Deletes the wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// Name of the page. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.DeleteWikiPageAsync(projectId, pageName, requestOptions).ConfigureAwait(false); - } - - /// - /// Support for adding attachments through the REST API is added in Redmine 1.4.0. - /// Upload a file to server. This method does not block the calling thread. - /// - /// The redmine manager. - /// The content of the file that will be uploaded on server. - /// - /// . - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task UploadFileAsync(this RedmineManager redmineManager, byte[] data) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - return await redmineManager.UploadFileAsync(data, null, requestOptions).ConfigureAwait(false); - } - - /// - /// Downloads the file asynchronous. - /// - /// The redmine manager. - /// The address. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task DownloadFileAsync(this RedmineManager redmineManager, string address) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - - return await redmineManager.DownloadFileAsync(address, requestOptions).ConfigureAwait(false); - } - - /// - /// Gets the wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// The parameters. - /// Name of the page. - /// The version. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId, NameValueCollection parameters, string pageName, uint version = 0) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.GetWikiPageAsync(projectId, pageName, requestOptions, version).ConfigureAwait(false); - } - - /// - /// Gets all wiki pages asynchronous. - /// - /// The redmine manager. - /// The parameters. - /// The project identifier. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, NameValueCollection parameters, string projectId) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.GetAllWikiPagesAsync(projectId, requestOptions).ConfigureAwait(false); - } - - /// - /// Adds an existing user to a group. This method does not block the calling thread. - /// - /// The redmine manager. - /// The group id. - /// The user id. - /// - /// Returns the Guid associated with the async request. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.AddUserToGroupAsync(groupId, userId, requestOptions).ConfigureAwait(false); - } - - /// - /// Removes an user from a group. This method does not block the calling thread. - /// - /// The redmine manager. - /// The group id. - /// The user id. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.RemoveUserFromGroupAsync(groupId, userId, requestOptions).ConfigureAwait(false); - } - - /// - /// Adds the watcher asynchronous. - /// - /// The redmine manager. - /// The issue identifier. - /// The user identifier. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.AddWatcherToIssueAsync(issueId, userId, requestOptions).ConfigureAwait(false); - } - - /// - /// Removes the watcher asynchronous. - /// - /// The redmine manager. - /// The issue identifier. - /// The user identifier. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId) - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.RemoveWatcherFromIssueAsync(issueId, userId, requestOptions).ConfigureAwait(false); - } - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use the extension with RequestOptions parameter instead.")] - public static async Task CountAsync(this RedmineManager redmineManager, params string[] include) where T : class, new() - { - return await redmineManager.CountAsync(null, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use CountAsync method instead.")] - public static async Task CountAsync(this RedmineManager redmineManager, NameValueCollection parameters) where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.CountAsync(requestOptions).ConfigureAwait(false); - } - - - /// - /// Gets the paginated objects asynchronous. - /// - /// - /// The redmine manager. - /// The parameters. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use GetPagedAsync method instead.")] - public static async Task> GetPaginatedObjectsAsync(this RedmineManager redmineManager, NameValueCollection parameters) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.GetPagedAsync(requestOptions).ConfigureAwait(false); - } - - /// - /// Gets the objects asynchronous. - /// - /// - /// The redmine manager. - /// The parameters. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use GetAsync method instead.")] - public static async Task> GetObjectsAsync(this RedmineManager redmineManager, NameValueCollection parameters) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.GetAsync(requestOptions).ConfigureAwait(false); - } - - /// - /// Gets a Redmine object. This method does not block the calling thread. - /// - /// The type of objects to retrieve. - /// The redmine manager. - /// The id of the object. - /// Optional filters and/or optional fetched data. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use GetAsync method instead.")] - public static async Task GetObjectAsync(this RedmineManager redmineManager, string id, NameValueCollection parameters) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(parameters); - return await redmineManager.GetAsync(id, requestOptions).ConfigureAwait(false); - } - - /// - /// Creates a new Redmine object. This method does not block the calling thread. - /// - /// The type of object to create. - /// The redmine manager. - /// The object to create. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use CreateAsync method instead.")] - public static async Task CreateObjectAsync(this RedmineManager redmineManager, T entity) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - return await redmineManager.CreateAsync(entity, null, requestOptions).ConfigureAwait(false); - } - - /// - /// Creates a new Redmine object. This method does not block the calling thread. - /// - /// The type of object to create. - /// The redmine manager. - /// The object to create. - /// The owner identifier. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use CreateAsync method instead.")] - public static async Task CreateObjectAsync(this RedmineManager redmineManager, T entity, string ownerId) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - return await redmineManager.CreateAsync(entity, ownerId, requestOptions, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Updates the object asynchronous. - /// - /// - /// The redmine manager. - /// The identifier. - /// The object. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use UpdateAsync method instead.")] - public static async Task UpdateObjectAsync(this RedmineManager redmineManager, string id, T entity) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.UpdateAsync(id, entity, requestOptions).ConfigureAwait(false); - } - - /// - /// Deletes the Redmine object. This method does not block the calling thread. - /// - /// The type of objects to delete. - /// The redmine manager. - /// The id of the object to delete - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use DeleteAsync method instead.")] - public static async Task DeleteObjectAsync(this RedmineManager redmineManager, string id) - where T : class, new() - { - var requestOptions = RedmineManagerExtensions.CreateRequestOptions(); - await redmineManager.DeleteAsync(id, requestOptions).ConfigureAwait(false); - } - } -} -#endif \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs b/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs index fb94205f..71ebcf13 100644 --- a/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs +++ b/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs @@ -17,13 +17,15 @@ limitations under the License. using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.Globalization; +using Redmine.Net.Api.Common; #if !(NET20) using System.Threading; using System.Threading.Tasks; #endif using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Net; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Extensions; +using Redmine.Net.Api.Net.Internal; using Redmine.Net.Api.Serialization; using Redmine.Net.Api.Types; @@ -35,132 +37,124 @@ namespace Redmine.Net.Api.Extensions public static class RedmineManagerExtensions { /// - /// + /// Archives a project in Redmine based on the specified project identifier. /// - /// - /// - /// - /// - public static void ArchiveProject(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project to be archived. + /// Additional request options to include in the API call. + public static void ArchiveProject(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectArchive(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Update(escapedUri, string.Empty ,requestOptions); + redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions); } - + /// - /// + /// Unarchives a project in Redmine based on the specified project identifier. /// - /// - /// - /// - /// - public static void UnarchiveProject(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project to be unarchived. + /// Additional request options to include in the API call. + public static void UnarchiveProject(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectUnarchive(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Update(escapedUri, string.Empty ,requestOptions); + redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions); } - + /// - /// + /// Reopens a previously closed project in Redmine based on the specified project identifier. /// - /// - /// - /// - /// - public static void ReopenProject(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to execute the API request. + /// The unique identifier of the project to be reopened. + /// Additional request options to include in the API call. + public static void ReopenProject(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectReopen(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Update(escapedUri, string.Empty ,requestOptions); + redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions); } - + /// - /// + /// Closes a project in Redmine based on the specified project identifier. /// - /// - /// - /// - /// - public static void CloseProject(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project to be closed. + /// Additional request options to include in the API call. + public static void CloseProject(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectClose(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Update(escapedUri,string.Empty, requestOptions); + redmineManager.ApiClient.Update(uri,string.Empty, requestOptions); } - + /// - /// + /// Adds a related issue to a project repository in Redmine based on the specified parameters. /// - /// - /// - /// - /// - /// - public static void ProjectRepositoryAddRelatedIssue(this RedmineManager redmineManager, string projectIdentifier, string repositoryIdentifier, string revision, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project to which the repository belongs. + /// The unique identifier of the repository within the project. + /// The revision or commit ID to relate the issue to. + /// Additional request options to include in the API call. + public static void ProjectRepositoryAddRelatedIssue(this RedmineManager redmineManager, + string projectIdentifier, string repositoryIdentifier, string revision, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectRepositoryAddRelatedIssue(projectIdentifier, repositoryIdentifier, revision); - var escapedUri = Uri.EscapeDataString(uri); - - _ = redmineManager.ApiClient.Create(escapedUri,string.Empty, requestOptions); + _ = redmineManager.ApiClient.Create(uri,string.Empty, requestOptions); } /// - /// + /// Removes a related issue from the specified repository revision of a project in Redmine. /// - /// - /// - /// - /// - /// - /// - public static void ProjectRepositoryRemoveRelatedIssue(this RedmineManager redmineManager, string projectIdentifier, string repositoryIdentifier, string revision, string issueIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project containing the repository. + /// The unique identifier of the repository from which the related issue will be removed. + /// The specific revision of the repository to disassociate the issue from. + /// The unique identifier of the issue to be removed as related. + /// Additional request options to include in the API call. + public static void ProjectRepositoryRemoveRelatedIssue(this RedmineManager redmineManager, + string projectIdentifier, string repositoryIdentifier, string revision, string issueIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectRepositoryRemoveRelatedIssue(projectIdentifier, repositoryIdentifier, revision, issueIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - _ = redmineManager.ApiClient.Delete(escapedUri, requestOptions); + _ = redmineManager.ApiClient.Delete(uri, requestOptions); } - + /// - /// + /// Retrieves a paginated list of news for a specific project in Redmine. /// - /// - /// - /// - /// - public static PagedResults GetProjectNews(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project for which news is being retrieved. + /// Additional request options to include in the API call, if any. + /// A paginated list of news items associated with the specified project. + public static PagedResults GetProjectNews(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - var response = redmineManager.GetPaginatedInternal(escapedUri, requestOptions); + var response = redmineManager.GetPaginatedInternal(uri, requestOptions); return response; } /// - /// + /// Adds a news item to a project in Redmine based on the specified project identifier. /// - /// - /// - /// - /// - /// - /// - public static News AddProjectNews(this RedmineManager redmineManager, string projectIdentifier, News news, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project to which the news will be added. + /// The news item to be added to the project, which must contain a valid title. + /// Additional request options to include in the API call. + /// The created news item as a response from the Redmine server. + /// Thrown when the provided news object is null or the news title is blank. + public static News AddProjectNews(this RedmineManager redmineManager, string projectIdentifier, News news, + RequestOptions requestOptions = null) { if (news == null) { @@ -174,24 +168,23 @@ public static News AddProjectNews(this RedmineManager redmineManager, string pro var payload = redmineManager.Serializer.Serialize(news); - var uri = Uri.EscapeDataString(redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier)); - - var escapedUri = Uri.EscapeDataString(uri); + var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier); - var response = redmineManager.ApiClient.Create(escapedUri, payload, requestOptions); + var response = redmineManager.ApiClient.Create(uri, payload, requestOptions); return response.DeserializeTo(redmineManager.Serializer); } /// - /// + /// Retrieves the memberships associated with the specified project in Redmine. /// - /// - /// - /// - /// - /// - public static PagedResults GetProjectMemberships(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project for which memberships are being retrieved. + /// Additional request options to include in the API call, such as pagination or filters. + /// Returns a paginated collection of project memberships for the specified project. + /// Thrown when the API request fails or an error occurs during execution. + public static PagedResults GetProjectMemberships(this RedmineManager redmineManager, + string projectIdentifier, RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectMemberships(projectIdentifier); @@ -201,14 +194,15 @@ public static PagedResults GetProjectMemberships(this Redmine } /// - /// + /// Retrieves the list of files associated with a specific project in Redmine. /// - /// - /// - /// - /// - /// - public static PagedResults GetProjectFiles(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the project whose files are being retrieved. + /// Additional request options to include in the API call. + /// A paginated result containing the list of files associated with the project. + /// Thrown when the API request fails or returns an error response. + public static PagedResults GetProjectFiles(this RedmineManager redmineManager, string projectIdentifier, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectFilesFragment(projectIdentifier); @@ -220,9 +214,9 @@ public static PagedResults GetProjectFiles(this RedmineManager redmineMana /// /// Returns the user whose credentials are used to access the API. /// - /// - /// - /// + /// The instance of the RedmineManager used to manage the API requests. + /// Additional request options to include in the API call. + /// The authenticated user as a object. public static User GetCurrentUser(this RedmineManager redmineManager, RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.CurrentUser(); @@ -231,11 +225,13 @@ public static User GetCurrentUser(this RedmineManager redmineManager, RequestOpt return response.DeserializeTo(redmineManager.Serializer); } - + /// - /// + /// Retrieves the account details of the currently authenticated user. /// - /// Returns the my account details. + /// The instance of the RedmineManager used to perform the API call. + /// Optional configuration for the API request. + /// Returns the account details of the authenticated user as a MyAccount object. public static MyAccount GetMyAccount(this RedmineManager redmineManager, RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.MyAccount(); @@ -246,15 +242,16 @@ public static MyAccount GetMyAccount(this RedmineManager redmineManager, Request } /// - /// Adds the watcher to issue. + /// Adds a watcher to a specific issue in Redmine using the specified issue ID and user ID. /// - /// - /// The issue identifier. - /// The user identifier. - /// - public static void AddWatcherToIssue(this RedmineManager redmineManager, int issueId, int userId, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the issue to which the watcher will be added. + /// The unique identifier of the user to be added as a watcher. + /// Additional request options to include in the API call. + public static void AddWatcherToIssue(this RedmineManager redmineManager, int issueId, int userId, + RequestOptions requestOptions = null) { - var uri = redmineManager.RedmineApiUrls.IssueWatcherAdd(issueId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.IssueWatcherAdd(issueId.ToInvariantString()); var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer); @@ -262,29 +259,31 @@ public static void AddWatcherToIssue(this RedmineManager redmineManager, int iss } /// - /// Removes the watcher from issue. + /// Removes a watcher from a specific issue in Redmine based on the specified issue identifier and user identifier. /// - /// - /// The issue identifier. - /// The user identifier. - /// - public static void RemoveWatcherFromIssue(this RedmineManager redmineManager, int issueId, int userId, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage the API requests. + /// The unique identifier of the issue from which the watcher will be removed. + /// The unique identifier of the user to be removed as a watcher. + /// Additional request options to include in the API call. + public static void RemoveWatcherFromIssue(this RedmineManager redmineManager, int issueId, int userId, + RequestOptions requestOptions = null) { - var uri = redmineManager.RedmineApiUrls.IssueWatcherRemove(issueId.ToString(CultureInfo.InvariantCulture), userId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.IssueWatcherRemove(issueId.ToInvariantString(), userId.ToInvariantString()); redmineManager.ApiClient.Delete(uri, requestOptions); } /// - /// Adds an existing user to a group. + /// Adds a user to a specified group in Redmine. /// - /// - /// The group id. - /// The user id. - /// - public static void AddUserToGroup(this RedmineManager redmineManager, int groupId, int userId, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage API requests. + /// The unique identifier of the group to which the user will be added. + /// The unique identifier of the user to be added to the group. + /// Additional request options to include in the API call. + public static void AddUserToGroup(this RedmineManager redmineManager, int groupId, int userId, + RequestOptions requestOptions = null) { - var uri = redmineManager.RedmineApiUrls.GroupUserAdd(groupId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.GroupUserAdd(groupId.ToInvariantString()); var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer); @@ -292,29 +291,30 @@ public static void AddUserToGroup(this RedmineManager redmineManager, int groupI } /// - /// Removes an user from a group. + /// Removes a user from a specified group in Redmine. /// - /// - /// The group id. - /// The user id. - /// - public static void RemoveUserFromGroup(this RedmineManager redmineManager, int groupId, int userId, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage API requests. + /// The unique identifier of the group from which the user will be removed. + /// The unique identifier of the user to be removed from the group. + /// Additional request options to customize the API call. + public static void RemoveUserFromGroup(this RedmineManager redmineManager, int groupId, int userId, + RequestOptions requestOptions = null) { - var uri = redmineManager.RedmineApiUrls.GroupUserRemove(groupId.ToString(CultureInfo.InvariantCulture), userId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.GroupUserRemove(groupId.ToInvariantString(), userId.ToInvariantString()); redmineManager.ApiClient.Delete(uri, requestOptions); } /// - /// Creates or updates a wiki page. + /// Updates a specified wiki page for a project in Redmine. /// - /// - /// The project id or identifier. - /// The wiki page name. - /// The wiki page to create or update. - /// - /// - public static void UpdateWikiPage(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to process the request. + /// The unique identifier of the project containing the wiki page. + /// The name of the wiki page to be updated. + /// The WikiPage object containing the updated data for the page. + /// Optional parameters for customizing the API request. + public static void UpdateWikiPage(this RedmineManager redmineManager, string projectId, string pageName, + WikiPage wikiPage, RequestOptions requestOptions = null) { var payload = redmineManager.Serializer.Serialize(wikiPage); @@ -325,21 +325,21 @@ public static void UpdateWikiPage(this RedmineManager redmineManager, string pro var uri = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Patch(escapedUri, payload, requestOptions); + redmineManager.ApiClient.Patch(uri, payload, requestOptions); } /// - /// + /// Creates a new wiki page within a specified project in Redmine. /// - /// - /// - /// - /// - /// - /// - public static WikiPage CreateWikiPage(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage API requests. + /// The unique identifier of the project where the wiki page will be created. + /// The name of the new wiki page. + /// The WikiPage object containing the content and metadata for the new page. + /// Additional request options to include in the API call. + /// The created WikiPage object containing the details of the new wiki page. + /// Thrown when the request payload is empty or if the API request fails. + public static WikiPage CreateWikiPage(this RedmineManager redmineManager, string projectId, string pageName, + WikiPage wikiPage, RequestOptions requestOptions = null) { var payload = redmineManager.Serializer.Serialize(wikiPage); @@ -350,43 +350,41 @@ public static WikiPage CreateWikiPage(this RedmineManager redmineManager, string var uri = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName); - var escapedUri = Uri.EscapeDataString(uri); - - var response = redmineManager.ApiClient.Create(escapedUri, payload, requestOptions); + var response = redmineManager.ApiClient.Update(uri, payload, requestOptions); return response.DeserializeTo(redmineManager.Serializer); } /// - /// Gets the wiki page. + /// Retrieves a wiki page from a Redmine project using the specified project identifier and page name. /// - /// - /// The project identifier. - /// Name of the page. - /// - /// The version. - /// - public static WikiPage GetWikiPage(this RedmineManager redmineManager, string projectId, string pageName, RequestOptions requestOptions = null, uint version = 0) + /// The instance of the RedmineManager responsible for managing API requests. + /// The unique identifier of the project containing the wiki page. + /// The name of the wiki page to retrieve. + /// Additional options to include in the API request, such as headers or query parameters. + /// The specific version of the wiki page to retrieve. If 0, the latest version is retrieved. + /// A WikiPage object containing the details of the requested wiki page. + public static WikiPage GetWikiPage(this RedmineManager redmineManager, string projectId, string pageName, + RequestOptions requestOptions = null, uint version = 0) { var uri = version == 0 ? redmineManager.RedmineApiUrls.ProjectWikiPage(projectId, pageName) - : redmineManager.RedmineApiUrls.ProjectWikiPageVersion(projectId, pageName, version.ToString(CultureInfo.InvariantCulture)); + : redmineManager.RedmineApiUrls.ProjectWikiPageVersion(projectId, pageName, version.ToInvariantString()); - var escapedUri = Uri.EscapeDataString(uri); - - var response = redmineManager.ApiClient.Get(escapedUri, requestOptions); + var response = redmineManager.ApiClient.Get(uri, requestOptions); return response.DeserializeTo(redmineManager.Serializer); } /// - /// Returns the list of all pages in a project wiki. + /// Retrieves all wiki pages associated with the specified project. /// - /// - /// The project id or identifier. - /// - /// - public static List GetAllWikiPages(this RedmineManager redmineManager, string projectId, RequestOptions requestOptions = null) + /// The instance of the RedmineManager used to manage API requests. + /// The unique identifier of the project whose wiki pages are to be fetched. + /// Additional request options to include in the API call. + /// A list of wiki pages associated with the specified project. + public static List GetAllWikiPages(this RedmineManager redmineManager, string projectId, + RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectWikiIndex(projectId); @@ -399,17 +397,15 @@ public static List GetAllWikiPages(this RedmineManager redmineManager, /// Deletes a wiki page, its attachments and its history. If the deleted page is a parent page, its child pages are not /// deleted but changed as root pages. /// - /// + /// The instance of the RedmineManager used to manage API requests. /// The project id or identifier. /// The wiki page name. - /// + /// Additional request options to include in the API call. public static void DeleteWikiPage(this RedmineManager redmineManager, string projectId, string pageName, RequestOptions requestOptions = null) { var uri = redmineManager.RedmineApiUrls.ProjectWikiPageDelete(projectId, pageName); - var escapedUri = Uri.EscapeDataString(uri); - - redmineManager.ApiClient.Delete(escapedUri, requestOptions); + redmineManager.ApiClient.Delete(uri, requestOptions); } /// @@ -418,7 +414,7 @@ public static void DeleteWikiPage(this RedmineManager redmineManager, string pro /// /// The issue identifier. /// The attachment. - /// + /// Additional request options to include in the API call. public static void UpdateIssueAttachment(this RedmineManager redmineManager, int issueId, Attachment attachment, RequestOptions requestOptions = null) { var attachments = new Attachments @@ -428,7 +424,7 @@ public static void UpdateIssueAttachment(this RedmineManager redmineManager, int var data = redmineManager.Serializer.Serialize(attachments); - var uri = redmineManager.RedmineApiUrls.AttachmentUpdate(issueId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.AttachmentUpdate(issueId.ToInvariantString()); redmineManager.ApiClient.Patch(uri, data, requestOptions); } @@ -449,7 +445,11 @@ public static PagedResults Search(this RedmineManager redmineManager, st { var parameters = CreateSearchParameters(q, limit, offset, searchFilter); - var response = redmineManager.GetPaginated(new RequestOptions() {QueryString = parameters}); + var response = redmineManager.GetPaginated(new RequestOptions + { + QueryString = parameters, + ImpersonateUser = impersonateUserName + }); return response; } @@ -464,8 +464,8 @@ private static NameValueCollection CreateSearchParameters(string q, int limit, i var parameters = new NameValueCollection { {RedmineKeys.Q, q}, - {RedmineKeys.LIMIT, limit.ToString(CultureInfo.InvariantCulture)}, - {RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture)}, + {RedmineKeys.LIMIT, limit.ToInvariantString()}, + {RedmineKeys.OFFSET, offset.ToInvariantString()}, }; return searchFilter != null ? searchFilter.Build(parameters) : parameters; @@ -478,15 +478,13 @@ private static NameValueCollection CreateSearchParameters(string q, int limit, i /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task ArchiveProjectAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectArchive(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.DeleteAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -494,15 +492,13 @@ public static async Task ArchiveProjectAsync(this RedmineManager redmineManager, /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task UnarchiveProjectAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectUnarchive(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.DeleteAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -510,15 +506,13 @@ public static async Task UnarchiveProjectAsync(this RedmineManager redmineManage /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task CloseProjectAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectClose(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.UpdateAsync(escapedUri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.UpdateAsync(uri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -526,15 +520,13 @@ public static async Task CloseProjectAsync(this RedmineManager redmineManager, s /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task ReopenProjectAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectReopen(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.UpdateAsync(escapedUri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.UpdateAsync(uri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -544,15 +536,13 @@ public static async Task ReopenProjectAsync(this RedmineManager redmineManager, /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task ProjectRepositoryAddRelatedIssueAsync(this RedmineManager redmineManager, string projectIdentifier, string repositoryIdentifier, string revision, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectRepositoryAddRelatedIssue(projectIdentifier, repositoryIdentifier, revision); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.CreateAsync(escapedUri, string.Empty ,requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.CreateAsync(uri, string.Empty ,requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -563,15 +553,13 @@ public static async Task ProjectRepositoryAddRelatedIssueAsync(this RedmineManag /// /// /// - /// + /// Additional request options to include in the API call. /// public static async Task ProjectRepositoryRemoveRelatedIssueAsync(this RedmineManager redmineManager, string projectIdentifier, string repositoryIdentifier, string revision, string issueIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectRepositoryRemoveRelatedIssue(projectIdentifier, repositoryIdentifier, revision, issueIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.DeleteAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -579,16 +567,14 @@ public static async Task ProjectRepositoryRemoveRelatedIssueAsync(this RedmineMa /// /// /// - /// + /// Additional request options to include in the API call. /// /// public static async Task> GetProjectNewsAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - var response = await redmineManager.ApiClient.GetPagedAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + var response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); return response.DeserializeToPagedResults(redmineManager.Serializer); } @@ -599,7 +585,7 @@ public static async Task> GetProjectNewsAsync(this RedmineMan /// /// /// - /// + /// Additional request options to include in the API call. /// /// /// @@ -617,11 +603,9 @@ public static async Task AddProjectNewsAsync(this RedmineManager redmineMa var payload = redmineManager.Serializer.Serialize(news); - var uri = Uri.EscapeDataString(redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier)); + var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier); - var escapedUri = Uri.EscapeDataString(uri); - - var response = await redmineManager.ApiClient.CreateAsync(escapedUri, payload, requestOptions, cancellationToken).ConfigureAwait(false); + var response = await redmineManager.ApiClient.CreateAsync(uri, payload, requestOptions, cancellationToken).ConfigureAwait(false); return response.DeserializeTo(redmineManager.Serializer); } @@ -631,7 +615,7 @@ public static async Task AddProjectNewsAsync(this RedmineManager redmineMa /// /// /// - /// + /// Additional request options to include in the API call. /// /// /// @@ -649,7 +633,7 @@ public static async Task> GetProjectMembershipsA /// /// /// - /// + /// Additional request options to include in the API call. /// /// /// @@ -661,7 +645,6 @@ public static async Task> GetProjectFilesAsync(this RedmineMa return response.DeserializeToPagedResults(redmineManager.Serializer); } - /// /// @@ -673,23 +656,28 @@ public static async Task> GetProjectFilesAsync(this RedmineMa /// /// /// - public static async Task> SearchAsync(this RedmineManager redmineManager, string q, int limit = RedmineManager.DEFAULT_PAGE_SIZE_VALUE, int offset = 0, SearchFilterBuilder searchFilter = null, CancellationToken cancellationToken = default) + public static async Task> SearchAsync(this RedmineManager redmineManager, + string q, + int limit = RedmineConstants.DEFAULT_PAGE_SIZE_VALUE, + int offset = 0, + SearchFilterBuilder searchFilter = null, + CancellationToken cancellationToken = default) { var parameters = CreateSearchParameters(q, limit, offset, searchFilter); - var response = await redmineManager.ApiClient.GetPagedAsync("", new RequestOptions() + var response = await redmineManager.GetPagedAsync(new RequestOptions() { QueryString = parameters }, cancellationToken).ConfigureAwait(false); - return response.DeserializeToPagedResults(redmineManager.Serializer); + return response; } /// /// /// /// - /// + /// Additional request options to include in the API call. /// /// public static async Task GetCurrentUserAsync(this RedmineManager redmineManager, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) @@ -701,6 +689,22 @@ public static async Task GetCurrentUserAsync(this RedmineManager redmineMa return response.DeserializeTo(redmineManager.Serializer); } + /// + /// Retrieves the account details of the currently authenticated user. + /// + /// The instance of the RedmineManager used to perform the API call. + /// Optional configuration for the API request. + /// + /// Returns the account details of the authenticated user as a MyAccount object. + public static async Task GetMyAccountAsync(this RedmineManager redmineManager, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var uri = redmineManager.RedmineApiUrls.MyAccount(); + + var response = await redmineManager.ApiClient.GetAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); + + return response.DeserializeTo(redmineManager.Serializer); + } + /// /// Creates or updates wiki page asynchronous. /// @@ -708,23 +712,26 @@ public static async Task GetCurrentUserAsync(this RedmineManager redmineMa /// The project identifier. /// Name of the page. /// The wiki page. - /// + /// Additional request options to include in the API call. /// /// public static async Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var payload = redmineManager.Serializer.Serialize(wikiPage); + if (pageName.IsNullOrWhiteSpace()) + { + throw new RedmineException("Page name cannot be blank"); + } + if (string.IsNullOrEmpty(payload)) { throw new RedmineException("The payload is empty"); } - var url = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName); + var path = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName); - var escapedUri = Uri.EscapeDataString(url); - - var response = await redmineManager.ApiClient.CreateAsync(escapedUri, payload,requestOptions, cancellationToken).ConfigureAwait(false); + var response = await redmineManager.ApiClient.UpdateAsync(path, payload, requestOptions, cancellationToken).ConfigureAwait(false); return response.DeserializeTo(redmineManager.Serializer); } @@ -736,7 +743,7 @@ public static async Task CreateWikiPageAsync(this RedmineManager redmi /// The project identifier. /// Name of the page. /// The wiki page. - /// + /// Additional request options to include in the API call. /// /// public static async Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) @@ -750,9 +757,7 @@ public static async Task UpdateWikiPageAsync(this RedmineManager redmineManager, var url = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName); - var escapedUri = Uri.EscapeDataString(url); - - await redmineManager.ApiClient.PatchAsync(escapedUri, payload, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.PatchAsync(url, payload, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -761,16 +766,14 @@ public static async Task UpdateWikiPageAsync(this RedmineManager redmineManager, /// The redmine manager. /// The project identifier. /// Name of the page. - /// + /// Additional request options to include in the API call. /// /// public static async Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var uri = redmineManager.RedmineApiUrls.ProjectWikiPageDelete(projectId, pageName); - var escapedUri = Uri.EscapeDataString(uri); - - await redmineManager.ApiClient.DeleteAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } /// @@ -779,7 +782,7 @@ public static async Task DeleteWikiPageAsync(this RedmineManager redmineManager, /// The redmine manager. /// The project identifier. /// Name of the page. - /// + /// Additional request options to include in the API call. /// The version. /// /// @@ -787,11 +790,9 @@ public static async Task GetWikiPageAsync(this RedmineManager redmineM { var uri = version == 0 ? redmineManager.RedmineApiUrls.ProjectWikiPage(projectId, pageName) - : redmineManager.RedmineApiUrls.ProjectWikiPageVersion(projectId, pageName, version.ToString(CultureInfo.InvariantCulture)); + : redmineManager.RedmineApiUrls.ProjectWikiPageVersion(projectId, pageName, version.ToInvariantString()); - var escapedUri = Uri.EscapeDataString(uri); - - var response = await redmineManager.ApiClient.GetAsync(escapedUri, requestOptions, cancellationToken).ConfigureAwait(false); + var response = await redmineManager.ApiClient.GetAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); return response.DeserializeTo(redmineManager.Serializer); } @@ -801,7 +802,7 @@ public static async Task GetWikiPageAsync(this RedmineManager redmineM /// /// The redmine manager. /// The project identifier. - /// + /// Additional request options to include in the API call. /// /// public static async Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, string projectId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) @@ -810,7 +811,8 @@ public static async Task> GetAllWikiPagesAsync(this RedmineManage var response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); - return response.DeserializeToList(redmineManager.Serializer); + var pages = response.DeserializeToPagedResults(redmineManager.Serializer); + return pages.Items as List; } /// @@ -819,14 +821,14 @@ public static async Task> GetAllWikiPagesAsync(this RedmineManage /// The redmine manager. /// The group id. /// The user id. - /// + /// Additional request options to include in the API call. /// /// /// Returns the Guid associated with the async request. /// public static async Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { - var uri = redmineManager.RedmineApiUrls.GroupUserAdd(groupId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.GroupUserAdd(groupId.ToInvariantString()); var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer); @@ -839,12 +841,12 @@ public static async Task AddUserToGroupAsync(this RedmineManager redmineManager, /// The redmine manager. /// The group id. /// The user id. - /// + /// Additional request options to include in the API call. /// /// public static async Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { - var uri = redmineManager.RedmineApiUrls.GroupUserRemove(groupId.ToString(CultureInfo.InvariantCulture), userId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.GroupUserRemove(groupId.ToInvariantString(), userId.ToInvariantString()); await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } @@ -855,12 +857,12 @@ public static async Task RemoveUserFromGroupAsync(this RedmineManager redmineMan /// The redmine manager. /// The issue identifier. /// The user identifier. - /// + /// Additional request options to include in the API call. /// /// public static async Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId, RequestOptions requestOptions = null , CancellationToken cancellationToken = default) { - var uri = redmineManager.RedmineApiUrls.IssueWatcherAdd(issueId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.IssueWatcherAdd(issueId.ToInvariantString()); var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer); @@ -873,37 +875,15 @@ public static async Task AddWatcherToIssueAsync(this RedmineManager redmineManag /// The redmine manager. /// The issue identifier. /// The user identifier. - /// + /// Additional request options to include in the API call. /// /// public static async Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { - var uri = redmineManager.RedmineApiUrls.IssueWatcherRemove(issueId.ToString(CultureInfo.InvariantCulture), userId.ToString(CultureInfo.InvariantCulture)); + var uri = redmineManager.RedmineApiUrls.IssueWatcherRemove(issueId.ToInvariantString(), userId.ToInvariantString()); await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false); } #endif - - internal static RequestOptions CreateRequestOptions(NameValueCollection parameters = null, string impersonateUserName = null) - { - RequestOptions requestOptions = null; - if (parameters != null) - { - requestOptions = new RequestOptions() - { - QueryString = parameters - }; - } - - if (impersonateUserName.IsNullOrWhiteSpace()) - { - return requestOptions; - } - - requestOptions ??= new RequestOptions(); - requestOptions.ImpersonateUser = impersonateUserName; - - return requestOptions; - } } } \ No newline at end of file diff --git a/src/redmine-net-api/Extensions/StringExtensions.cs b/src/redmine-net-api/Extensions/StringExtensions.cs index 03fdc059..a881f14d 100644 --- a/src/redmine-net-api/Extensions/StringExtensions.cs +++ b/src/redmine-net-api/Extensions/StringExtensions.cs @@ -28,10 +28,10 @@ namespace Redmine.Net.Api.Extensions public static class StringExtensions { /// - /// + /// Determines whether a string is null, empty, or consists only of white-space characters. /// - /// - /// + /// The string to evaluate. + /// True if the string is null, empty, or whitespace; otherwise, false. public static bool IsNullOrWhiteSpace(this string value) { if (value == null) @@ -39,9 +39,9 @@ public static bool IsNullOrWhiteSpace(this string value) return true; } - for (var index = 0; index < value.Length; ++index) + foreach (var ch in value) { - if (!char.IsWhiteSpace(value[index])) + if (!char.IsWhiteSpace(ch)) { return false; } @@ -51,11 +51,11 @@ public static bool IsNullOrWhiteSpace(this string value) } /// - /// + /// Truncates a string to the specified maximum length if it exceeds that length. /// - /// - /// - /// + /// The string to truncate. + /// The maximum allowed length for the string. + /// The truncated string if its length exceeds the maximum length; otherwise, the original string. public static string Truncate(this string text, int maximumLength) { if (text.IsNullOrWhiteSpace() || maximumLength < 1 || text.Length <= maximumLength) @@ -99,14 +99,19 @@ internal static SecureString ToSecureString(this string value) var rv = new SecureString(); - for (var index = 0; index < value.Length; ++index) + foreach (var ch in value) { - rv.AppendChar(value[index]); + rv.AppendChar(ch); } return rv; } + /// + /// Removes the trailing slash ('/' or '\') from the end of the string if it exists. + /// + /// The string to process. + /// The input string without a trailing slash, or the original string if no trailing slash exists. internal static string RemoveTrailingSlash(this string s) { if (string.IsNullOrEmpty(s)) @@ -128,12 +133,24 @@ internal static string RemoveTrailingSlash(this string s) return s; } - + + /// + /// Returns the specified string value if it is neither null, empty, nor consists only of white-space characters; otherwise, returns the fallback string. + /// + /// The primary string value to evaluate. + /// The fallback string to return if the primary string is null, empty, or consists of only white-space characters. + /// The original string if it is valid; otherwise, the fallback string. internal static string ValueOrFallback(this string value, string fallback) { return !value.IsNullOrWhiteSpace() ? value : fallback; } - + + /// + /// Converts a value of a struct type to its invariant culture string representation. + /// + /// The struct type of the value. + /// The value to convert to a string. + /// The invariant culture string representation of the value. internal static string ToInvariantString(this T value) where T : struct { return value switch @@ -152,16 +169,22 @@ internal static string ToInvariantString(this T value) where T : struct TimeSpan ts => ts.ToString(), DateTime d => d.ToString(CultureInfo.InvariantCulture), #pragma warning disable CA1308 - bool b => b.ToString().ToLowerInvariant(), + bool b => b ? "true" : "false", #pragma warning restore CA1308 _ => value.ToString(), }; } - private const string CRLR = "\r\n"; private const string CR = "\r"; private const string LR = "\n"; - + private const string CRLR = $"{CR}{LR}"; + + /// + /// Replaces all line endings in the input string with the specified replacement string. + /// + /// The string in which line endings will be replaced. + /// The string to replace line endings with. Defaults to a combination of carriage return and line feed. + /// The input string with all line endings replaced by the specified replacement string. internal static string ReplaceEndings(this string input, string replacement = CRLR) { if (input.IsNullOrWhiteSpace()) @@ -170,9 +193,9 @@ internal static string ReplaceEndings(this string input, string replacement = CR } #if NET6_0_OR_GREATER - input = input.ReplaceLineEndings(CRLR); + input = input.ReplaceLineEndings(replacement); #else - input = Regex.Replace(input, $"{CRLR}|{CR}|{LR}", CRLR); + input = Regex.Replace(input, $"{CRLR}|{CR}|{LR}", replacement); #endif return input; } diff --git a/src/redmine-net-api/Http/Clients/HttpClient/HttpClientProvider.cs b/src/redmine-net-api/Http/Clients/HttpClient/HttpClientProvider.cs new file mode 100644 index 00000000..3932402f --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/HttpClientProvider.cs @@ -0,0 +1,257 @@ +#if !NET20 +using System; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Options; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal static class HttpClientProvider +{ + private static System.Net.Http.HttpClient _client; + + /// + /// Gets an HttpClient instance. If an existing client is provided, it is returned; otherwise, a new one is created. + /// + public static System.Net.Http.HttpClient GetOrCreateHttpClient(System.Net.Http.HttpClient httpClient, + RedmineManagerOptions options) + { + if (_client != null) + { + return _client; + } + + _client = httpClient ?? CreateClient(options); + + return _client; + } + + /// + /// Creates a new HttpClient instance configured with the specified options. + /// + private static System.Net.Http.HttpClient CreateClient(RedmineManagerOptions redmineManagerOptions) + { + ArgumentVerifier.ThrowIfNull(redmineManagerOptions, nameof(redmineManagerOptions)); + + var handler = + #if NET + CreateSocketHandler(redmineManagerOptions); + #elif NETFRAMEWORK + CreateHandler(redmineManagerOptions); + #endif + + var client = new System.Net.Http.HttpClient(handler, disposeHandler: true); + + if (redmineManagerOptions.BaseAddress != null) + { + client.BaseAddress = redmineManagerOptions.BaseAddress; + } + + if (redmineManagerOptions.ApiClientOptions is not RedmineHttpClientOptions options) + { + return client; + } + + if (options.Timeout.HasValue) + { + client.Timeout = options.Timeout.Value; + } + + if (options.MaxResponseContentBufferSize.HasValue) + { + client.MaxResponseContentBufferSize = options.MaxResponseContentBufferSize.Value; + } + +#if NET5_0_OR_GREATER + if (options.DefaultRequestVersion != null) + { + client.DefaultRequestVersion = options.DefaultRequestVersion; + } + + if (options.DefaultVersionPolicy != null) + { + client.DefaultVersionPolicy = options.DefaultVersionPolicy.Value; + } +#endif + + return client; + } + +#if NET + private static SocketsHttpHandler CreateSocketHandler(RedmineManagerOptions redmineManagerOptions) + { + var handler = new SocketsHttpHandler() + { + // Limit the lifetime of connections to better respect any DNS changes + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + + // Check cert revocation + SslOptions = new SslClientAuthenticationOptions() + { + CertificateRevocationCheckMode = X509RevocationMode.Online, + }, + }; + + if (redmineManagerOptions.ApiClientOptions is not RedmineHttpClientOptions options) + { + return handler; + } + + if (options.CookieContainer != null) + { + handler.CookieContainer = options.CookieContainer; + } + + handler.Credentials = options.Credentials; + handler.Proxy = options.Proxy; + + if (options.AutoRedirect.HasValue) + { + handler.AllowAutoRedirect = options.AutoRedirect.Value; + } + + if (options.DecompressionFormat.HasValue) + { + handler.AutomaticDecompression = options.DecompressionFormat.Value; + } + + if (options.PreAuthenticate.HasValue) + { + handler.PreAuthenticate = options.PreAuthenticate.Value; + } + + if (options.UseCookies.HasValue) + { + handler.UseCookies = options.UseCookies.Value; + } + + if (options.UseProxy.HasValue) + { + handler.UseProxy = options.UseProxy.Value; + } + + if (options.MaxAutomaticRedirections.HasValue) + { + handler.MaxAutomaticRedirections = options.MaxAutomaticRedirections.Value; + } + + handler.DefaultProxyCredentials = options.DefaultProxyCredentials; + + if (options.MaxConnectionsPerServer.HasValue) + { + handler.MaxConnectionsPerServer = options.MaxConnectionsPerServer.Value; + } + + if (options.MaxResponseHeadersLength.HasValue) + { + handler.MaxResponseHeadersLength = options.MaxResponseHeadersLength.Value; + } + +#if NET8_0_OR_GREATER + handler.MeterFactory = options.MeterFactory; +#endif + + return handler; + } +#elif NETFRAMEWORK + private static HttpClientHandler CreateHandler(RedmineManagerOptions redmineManagerOptions) + { + var handler = new HttpClientHandler(); + return ConfigureHandler(handler, redmineManagerOptions); + } + + private static HttpClientHandler ConfigureHandler(HttpClientHandler handler, RedmineManagerOptions redmineManagerOptions) + { + if (redmineManagerOptions.ApiClientOptions is not RedmineHttpClientOptions options) + { + return handler; + } + + if (options.UseDefaultCredentials.HasValue) + { + handler.UseDefaultCredentials = options.UseDefaultCredentials.Value; + } + + if (options.CookieContainer != null) + { + handler.CookieContainer = options.CookieContainer; + } + + if (handler.SupportsAutomaticDecompression && options.DecompressionFormat.HasValue) + { + handler.AutomaticDecompression = options.DecompressionFormat.Value; + } + + if (handler.SupportsRedirectConfiguration) + { + if (options.AutoRedirect.HasValue) + { + handler.AllowAutoRedirect = options.AutoRedirect.Value; + } + + if (options.MaxAutomaticRedirections.HasValue) + { + handler.MaxAutomaticRedirections = options.MaxAutomaticRedirections.Value; + } + } + + if (options.ClientCertificateOptions != default) + { + handler.ClientCertificateOptions = options.ClientCertificateOptions; + } + + handler.Credentials = options.Credentials; + + if (options.UseProxy != null) + { + handler.UseProxy = options.UseProxy.Value; + if (handler.UseProxy && options.Proxy != null) + { + handler.Proxy = options.Proxy; + } + } + + if (options.PreAuthenticate.HasValue) + { + handler.PreAuthenticate = options.PreAuthenticate.Value; + } + + if (options.UseCookies.HasValue) + { + handler.UseCookies = options.UseCookies.Value; + } + + if (options.MaxRequestContentBufferSize.HasValue) + { + handler.MaxRequestContentBufferSize = options.MaxRequestContentBufferSize.Value; + } + +#if NET471_OR_GREATER + handler.CheckCertificateRevocationList = options.CheckCertificateRevocationList; + + if (options.DefaultProxyCredentials != null) + handler.DefaultProxyCredentials = options.DefaultProxyCredentials; + + if (options.ServerCertificateCustomValidationCallback != null) + handler.ServerCertificateCustomValidationCallback = options.ServerCertificateCustomValidationCallback; + + if (options.ServerCertificateValidationCallback != null) + handler.ServerCertificateCustomValidationCallback = options.ServerCertificateValidationCallback; + + handler.SslProtocols = options.SslProtocols; + + if (options.MaxConnectionsPerServer.HasValue) + handler.MaxConnectionsPerServer = options.MaxConnectionsPerServer.Value; + + if (options.MaxResponseHeadersLength.HasValue) + handler.MaxResponseHeadersLength = options.MaxResponseHeadersLength.Value; +#endif + + return handler; + } +#endif +} + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/HttpContentExtensions.cs b/src/redmine-net-api/Http/Clients/HttpClient/HttpContentExtensions.cs new file mode 100644 index 00000000..c1b2515c --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/HttpContentExtensions.cs @@ -0,0 +1,20 @@ +#if !NET20 + +using System.Net; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal static class HttpContentExtensions +{ + public static bool IsUnprocessableEntity(this HttpStatusCode statusCode) + { + return +#if NET5_0_OR_GREATER + statusCode == HttpStatusCode.UnprocessableEntity; +#else + (int)statusCode == 422; +#endif + } +} + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/HttpContentPolyfills.cs b/src/redmine-net-api/Http/Clients/HttpClient/HttpContentPolyfills.cs new file mode 100644 index 00000000..ec5d7f4d --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/HttpContentPolyfills.cs @@ -0,0 +1,35 @@ + +#if !(NET20 || NET5_0_OR_GREATER) + +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal static class HttpContentPolyfills +{ + internal static Task ReadAsStringAsync(this HttpContent httpContent, CancellationToken cancellationToken) + => httpContent.ReadAsStringAsync( +#if !NETFRAMEWORK + cancellationToken +#endif + ); + + internal static Task ReadAsStreamAsync(this HttpContent httpContent, CancellationToken cancellationToken) + => httpContent.ReadAsStreamAsync( +#if !NETFRAMEWORK + cancellationToken +#endif + ); + + internal static Task ReadAsByteArrayAsync(this HttpContent httpContent, CancellationToken cancellationToken) + => httpContent.ReadAsByteArrayAsync( +#if !NETFRAMEWORK + cancellationToken +#endif + ); +} + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/HttpResponseHeadersExtensions.cs b/src/redmine-net-api/Http/Clients/HttpClient/HttpResponseHeadersExtensions.cs new file mode 100644 index 00000000..37b44dcb --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/HttpResponseHeadersExtensions.cs @@ -0,0 +1,22 @@ +#if !NET20 +using System.Collections.Specialized; +using System.Net.Http.Headers; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal static class HttpResponseHeadersExtensions +{ + public static NameValueCollection ToNameValueCollection(this HttpResponseHeaders headers) + { + if (headers == null) return null; + + var collection = new NameValueCollection(); + foreach (var header in headers) + { + var combinedValue = string.Join(", ", header.Value); + collection.Add(header.Key, combinedValue); + } + return collection; + } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/IRedmineHttpClientOptions.cs b/src/redmine-net-api/Http/Clients/HttpClient/IRedmineHttpClientOptions.cs new file mode 100644 index 00000000..d8f6d9d2 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/IRedmineHttpClientOptions.cs @@ -0,0 +1,88 @@ +#if NET40_OR_GREATER || NET +using System; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +#if NET8_0_OR_GREATER +using System.Diagnostics.Metrics; +#endif + + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +/// +/// +/// +public interface IRedmineHttpClientOptions : IRedmineApiClientOptions +{ + /// + /// + /// + ClientCertificateOption ClientCertificateOptions { get; set; } + +#if NET471_OR_GREATER || NET + /// + /// + /// + ICredentials DefaultProxyCredentials { get; set; } + + /// + /// + /// + Func ServerCertificateCustomValidationCallback { get; set; } + + /// + /// + /// + SslProtocols SslProtocols { get; set; } +#endif + + /// + /// + /// + public +#if NET || NET471_OR_GREATER + Func +#else + RemoteCertificateValidationCallback +#endif + ServerCertificateValidationCallback { get; set; } + +#if NET8_0_OR_GREATER + /// + /// + /// + public IMeterFactory MeterFactory { get; set; } +#endif + + /// + /// + /// + bool SupportsAutomaticDecompression { get; set; } + + /// + /// + /// + bool SupportsProxy { get; set; } + + /// + /// + /// + bool SupportsRedirectConfiguration { get; set; } + + /// + /// + /// + Version DefaultRequestVersion { get; set; } + +#if NET + /// + /// + /// + HttpVersionPolicy? DefaultVersionPolicy { get; set; } +#endif +} + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.Async.cs b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.Async.cs new file mode 100644 index 00000000..77186037 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.Async.cs @@ -0,0 +1,144 @@ +#if !NET20 +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Http.Helpers; +using Redmine.Net.Api.Http.Messages; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal sealed partial class InternalRedmineApiHttpClient +{ + protected override async Task HandleRequestAsync(string address, string verb, RequestOptions requestOptions = null, + object content = null, IProgress progress = null, CancellationToken cancellationToken = default) + { + var httpMethod = GetHttpMethod(verb); + using (var requestMessage = CreateRequestMessage(address, httpMethod, requestOptions, content as HttpContent)) + { + return await SendAsync(requestMessage, progress: progress, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private async Task SendAsync(HttpRequestMessage requestMessage, IProgress progress = null, CancellationToken cancellationToken = default) + { + try + { + using (var httpResponseMessage = await _httpClient + .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false)) + { + if (httpResponseMessage.IsSuccessStatusCode) + { + if (httpResponseMessage.StatusCode == HttpStatusCode.NoContent) + { + return CreateApiResponseMessage(httpResponseMessage.Headers, HttpStatusCode.NoContent, []); + } + + byte[] data; + + if (requestMessage.Method == HttpMethod.Get && progress != null) + { + data = await DownloadWithProgressAsync(httpResponseMessage.Content, progress, cancellationToken) + .ConfigureAwait(false); + } + else + { + data = await httpResponseMessage.Content.ReadAsByteArrayAsync(cancellationToken) + .ConfigureAwait(false); + } + + return CreateApiResponseMessage(httpResponseMessage.Headers, httpResponseMessage.StatusCode, data); + } + + var statusCode = (int)httpResponseMessage.StatusCode; + using (var stream = await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false)) + { + RedmineExceptionHelper.MapStatusCodeToException(statusCode, stream, null, Serializer); + } + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + throw new RedmineApiException("Token has been cancelled", ex); + } + catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException tex) + { + throw new RedmineApiException("Operation has timed out", ex); + } + catch (TaskCanceledException tcex) when (cancellationToken.IsCancellationRequested) + { + throw new RedmineApiException("Operation ahs been cancelled by user", tcex); + } + catch (TaskCanceledException tce) + { + throw new RedmineApiException(tce.Message, tce); + } + catch (HttpRequestException ex) + { + throw new RedmineApiException(ex.Message, ex); + } + catch (Exception ex) when (ex is not RedmineException) + { + throw new RedmineApiException(ex.Message, ex); + } + + return null; + } + + private static async Task DownloadWithProgressAsync(HttpContent httpContent, IProgress progress = null, CancellationToken cancellationToken = default) + { + var contentLength = httpContent.Headers.ContentLength ?? -1; + byte[] data; + + if (contentLength > 0) + { + using (var stream = await httpContent.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + { + data = new byte[contentLength]; + int bytesRead; + var totalBytesRead = 0; + var buffer = new byte[8192]; + + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + Buffer.BlockCopy(buffer, 0, data, totalBytesRead, bytesRead); + totalBytesRead += bytesRead; + + var progressPercentage = (int)(totalBytesRead * 100 / contentLength); + progress?.Report(progressPercentage); + ReportProgress(progress, contentLength, totalBytesRead); + } + } + } + else + { + data = await httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + progress?.Report(100); + } + + return data; + } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.cs b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.cs new file mode 100644 index 00000000..a68897d6 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.cs @@ -0,0 +1,170 @@ +#if !NET20 +/* + Copyright 2011 - 2024 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http.Constants; +using Redmine.Net.Api.Http.Messages; +using Redmine.Net.Api.Options; + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +internal sealed partial class InternalRedmineApiHttpClient : RedmineApiClient +{ + private static readonly HttpMethod PatchMethod = new HttpMethod("PATCH"); + private static readonly HttpMethod DownloadMethod = new HttpMethod("DOWNLOAD"); + private static readonly Encoding DefaultEncoding = Encoding.UTF8; + + private readonly System.Net.Http.HttpClient _httpClient; + + public InternalRedmineApiHttpClient(RedmineManagerOptions redmineManagerOptions) + : this(null, redmineManagerOptions) + { + _httpClient = HttpClientProvider.GetOrCreateHttpClient(null, redmineManagerOptions); + } + + public InternalRedmineApiHttpClient(System.Net.Http.HttpClient httpClient, + RedmineManagerOptions redmineManagerOptions) + : base(redmineManagerOptions) + { + _httpClient = httpClient; + } + + protected override object CreateContentFromPayload(string payload) + { + return new StringContent(payload, DefaultEncoding, Serializer.ContentType); + } + + protected override object CreateContentFromBytes(byte[] data) + { + var content = new ByteArrayContent(data); + content.Headers.ContentType = new MediaTypeHeaderValue(RedmineConstants.CONTENT_TYPE_APPLICATION_STREAM); + return content; + } + + protected override RedmineApiResponse HandleRequest(string address, string verb, + RequestOptions requestOptions = null, + object content = null, IProgress progress = null) + { + var httpMethod = GetHttpMethod(verb); + using (var requestMessage = CreateRequestMessage(address, httpMethod, requestOptions, content as HttpContent)) + { + // LogRequest(verb, address, requestOptions); + + var response = Send(requestMessage, progress); + + // LogResponse(response.StatusCode); + + return response; + } + } + + private RedmineApiResponse Send(HttpRequestMessage requestMessage, IProgress progress = null) + { + return TaskExtensions.Synchronize(()=>SendAsync(requestMessage, progress)); + } + + private HttpRequestMessage CreateRequestMessage(string address, HttpMethod method, + RequestOptions requestOptions = null, HttpContent content = null) + { + var httpRequest = new HttpRequestMessage(method, address); + + switch (Credentials) + { + case RedmineApiKeyAuthentication: + httpRequest.Headers.Add(RedmineConstants.API_KEY_AUTHORIZATION_HEADER_KEY, Credentials.Token); + break; + case RedmineBasicAuthentication: + httpRequest.Headers.Add(RedmineConstants.AUTHORIZATION_HEADER_KEY, Credentials.Token); + break; + } + + if (requestOptions != null) + { + if (requestOptions.QueryString != null) + { + var uriToBeAppended = httpRequest.RequestUri.ToString(); + var queryIndex = uriToBeAppended.IndexOf("?", StringComparison.Ordinal); + var hasQuery = queryIndex != -1; + + var sb = new StringBuilder(); + sb.Append('\\'); + sb.Append(uriToBeAppended); + for (var index = 0; index < requestOptions.QueryString.Count; ++index) + { + var value = requestOptions.QueryString[index]; + + if (value == null) + { + continue; + } + + var key = requestOptions.QueryString.Keys[index]; + + sb.Append(hasQuery ? '&' : '?'); + sb.Append(Uri.EscapeDataString(key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(value)); + hasQuery = true; + } + + var uriString = sb.ToString(); + + httpRequest.RequestUri = new Uri(uriString, UriKind.RelativeOrAbsolute); + } + + if (!requestOptions.ImpersonateUser.IsNullOrWhiteSpace()) + { + httpRequest.Headers.Add(RedmineConstants.IMPERSONATE_HEADER_KEY, requestOptions.ImpersonateUser); + } + } + + if (content != null) + { + httpRequest.Content = content ; + } + + return httpRequest; + } + + private static RedmineApiResponse CreateApiResponseMessage(HttpResponseHeaders headers, HttpStatusCode statusCode, byte[] content) => new RedmineApiResponse() + { + Content = content, + Headers = headers.ToNameValueCollection(), + StatusCode = statusCode, + }; + + private static HttpMethod GetHttpMethod(string verb) + { + return verb switch + { + HttpConstants.HttpVerbs.GET => HttpMethod.Get, + HttpConstants.HttpVerbs.POST => HttpMethod.Post, + HttpConstants.HttpVerbs.PUT => HttpMethod.Put, + HttpConstants.HttpVerbs.PATCH => PatchMethod, + HttpConstants.HttpVerbs.DELETE => HttpMethod.Delete, + HttpConstants.HttpVerbs.DOWNLOAD => HttpMethod.Get, + _ => throw new ArgumentException($"Unsupported HTTP verb: {verb}") + }; + } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/HttpClient/RedmineHttpClientOptions.cs b/src/redmine-net-api/Http/Clients/HttpClient/RedmineHttpClientOptions.cs new file mode 100644 index 00000000..dc8e0b6a --- /dev/null +++ b/src/redmine-net-api/Http/Clients/HttpClient/RedmineHttpClientOptions.cs @@ -0,0 +1,75 @@ +#if !NET20 +using System; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +#if NET8_0_OR_GREATER +using System.Diagnostics.Metrics; +#endif + +namespace Redmine.Net.Api.Http.Clients.HttpClient; + +/// +/// +/// +public sealed class RedmineHttpClientOptions: RedmineApiClientOptions +{ +#if NET8_0_OR_GREATER + /// + /// + /// + public IMeterFactory MeterFactory { get; set; } +#endif + + /// + /// + /// + public Version DefaultRequestVersion { get; set; } + +#if NET + /// + /// + /// + public HttpVersionPolicy? DefaultVersionPolicy { get; set; } +#endif + /// + /// + /// + public ICredentials DefaultProxyCredentials { get; set; } + + /// + /// + /// + public ClientCertificateOption ClientCertificateOptions { get; set; } + + + +#if NETFRAMEWORK + /// + /// + /// + public Func ServerCertificateCustomValidationCallback + { + get; + set; + } + + /// + /// + /// + public SslProtocols SslProtocols { get; set; } + #endif + /// + /// + /// + public +#if NET || NET471_OR_GREATER + Func +#else + RemoteCertificateValidationCallback +#endif + ServerCertificateValidationCallback { get; set; } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.Async.cs b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.Async.cs new file mode 100644 index 00000000..a2678963 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.Async.cs @@ -0,0 +1,129 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +#if!(NET20) +using System; +using System.Collections.Specialized; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Http.Messages; + +namespace Redmine.Net.Api.Http.Clients.WebClient +{ + /// + /// + /// + internal sealed partial class InternalRedmineApiWebClient + { + protected override async Task HandleRequestAsync(string address, string verb, RequestOptions requestOptions = null, object content = null, + IProgress progress = null, CancellationToken cancellationToken = default) + { + return await SendAsync(CreateRequestMessage(address, verb, requestOptions, content as RedmineApiRequestContent), progress, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync(RedmineApiRequest requestMessage, IProgress progress = null, CancellationToken cancellationToken = default) + { + System.Net.WebClient webClient = null; + byte[] response = null; + HttpStatusCode? statusCode = null; + NameValueCollection responseHeaders = null; + CancellationTokenRegistration cancellationTokenRegistration = default; + + try + { + webClient = _webClientFunc(); + cancellationTokenRegistration = + cancellationToken.Register( + static state => ((System.Net.WebClient)state).CancelAsync(), + webClient + ); + + cancellationToken.ThrowIfCancellationRequested(); + + if (progress != null) + { + webClient.DownloadProgressChanged += (_, e) => + { + progress.Report(e.ProgressPercentage); + }; + } + + SetWebClientHeaders(webClient, requestMessage); + + if(IsGetOrDownload(requestMessage.Method)) + { + response = await webClient.DownloadDataTaskAsync(requestMessage.RequestUri) + .ConfigureAwait(false); + } + else + { + byte[] payload; + if (requestMessage.Content != null) + { + webClient.Headers.Add(HttpRequestHeader.ContentType, requestMessage.Content.ContentType); + payload = requestMessage.Content.Body; + } + else + { + payload = EmptyBytes; + } + + response = await webClient.UploadDataTaskAsync(requestMessage.RequestUri, requestMessage.Method, payload) + .ConfigureAwait(false); + } + + responseHeaders = webClient.ResponseHeaders; + if (webClient is InternalWebClient iwc) + { + statusCode = iwc.StatusCode; + } + } + catch (WebException ex) when (ex.Status == WebExceptionStatus.RequestCanceled) + { + if (cancellationToken.IsCancellationRequested) + { + throw new RedmineApiException("The operation was canceled by the user.", ex); + } + } + catch (WebException webException) + { + HandleWebException(webException, Serializer); + } + finally + { + #if NETFRAMEWORK + cancellationTokenRegistration.Dispose(); + #else + await cancellationTokenRegistration.DisposeAsync().ConfigureAwait(false); + #endif + + webClient?.Dispose(); + } + + return new RedmineApiResponse() + { + Headers = responseHeaders, + Content = response, + StatusCode = statusCode ?? HttpStatusCode.OK, + }; + } + } +} + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.cs b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.cs new file mode 100644 index 00000000..f08e3474 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.cs @@ -0,0 +1,282 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Collections.Specialized; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http.Constants; +using Redmine.Net.Api.Http.Extensions; +using Redmine.Net.Api.Http.Helpers; +using Redmine.Net.Api.Http.Messages; +using Redmine.Net.Api.Logging; +using Redmine.Net.Api.Options; +using Redmine.Net.Api.Serialization; + +namespace Redmine.Net.Api.Http.Clients.WebClient +{ + /// + /// + /// + internal sealed partial class InternalRedmineApiWebClient : RedmineApiClient + { + private static readonly byte[] EmptyBytes = Encoding.UTF8.GetBytes(string.Empty); + private readonly Func _webClientFunc; + + public InternalRedmineApiWebClient(RedmineManagerOptions redmineManagerOptions) + : this(() => new InternalWebClient(redmineManagerOptions), redmineManagerOptions) + { + } + + public InternalRedmineApiWebClient(Func webClientFunc, RedmineManagerOptions redmineManagerOptions) + : base(redmineManagerOptions) + { + _webClientFunc = webClientFunc; + } + + protected override object CreateContentFromPayload(string payload) + { + return RedmineApiRequestContent.CreateString(payload, Serializer.ContentType); + } + + protected override object CreateContentFromBytes(byte[] data) + { + return RedmineApiRequestContent.CreateBinary(data); + } + + protected override RedmineApiResponse HandleRequest(string address, string verb, RequestOptions requestOptions = null, object content = null, IProgress progress = null) + { + var requestMessage = CreateRequestMessage(address, verb, requestOptions, content as RedmineApiRequestContent); + + if (Options.LoggingOptions?.IncludeHttpDetails == true) + { + Options.Logger.Debug($"Request HTTP {verb} {address}"); + + if (requestOptions?.QueryString != null) + { + Options.Logger.Debug($"Query parameters: {requestOptions.QueryString.ToQueryString()}"); + } + } + + var responseMessage = Send(requestMessage, progress); + + if (Options.LoggingOptions?.IncludeHttpDetails == true) + { + Options.Logger.Debug($"Response status: {responseMessage.StatusCode}"); + } + + return responseMessage; + } + + private static RedmineApiRequest CreateRequestMessage(string address, string verb, RequestOptions requestOptions = null, RedmineApiRequestContent content = null) + { + var req = new RedmineApiRequest() + { + RequestUri = address, + Method = verb, + }; + + if (requestOptions != null) + { + req.QueryString = requestOptions.QueryString; + req.ImpersonateUser = requestOptions.ImpersonateUser; + } + + if (content != null) + { + req.Content = content; + } + + return req; + } + + private RedmineApiResponse Send(RedmineApiRequest requestMessage, IProgress progress = null) + { + System.Net.WebClient webClient = null; + byte[] response = null; + HttpStatusCode? statusCode = null; + NameValueCollection responseHeaders = null; + + try + { + webClient = _webClientFunc(); + + SetWebClientHeaders(webClient, requestMessage); + + if (IsGetOrDownload(requestMessage.Method)) + { + response = requestMessage.Method == HttpConstants.HttpVerbs.DOWNLOAD + ? DownloadWithProgress(requestMessage.RequestUri, webClient, progress) + : webClient.DownloadData(requestMessage.RequestUri); + } + else + { + byte[] payload; + if (requestMessage.Content != null) + { + webClient.Headers.Add(HttpRequestHeader.ContentType, requestMessage.Content.ContentType); + payload = requestMessage.Content.Body; + } + else + { + payload = EmptyBytes; + } + + response = webClient.UploadData(requestMessage.RequestUri, requestMessage.Method, payload); + } + + responseHeaders = webClient.ResponseHeaders; + if (webClient is InternalWebClient iwc) + { + statusCode = iwc.StatusCode; + } + } + catch (WebException webException) + { + HandleWebException(webException, Serializer); + } + finally + { + webClient?.Dispose(); + } + + return new RedmineApiResponse() + { + Headers = responseHeaders, + Content = response, + StatusCode = statusCode ?? HttpStatusCode.OK, + }; + } + + private void SetWebClientHeaders(System.Net.WebClient webClient, RedmineApiRequest requestMessage) + { + if (requestMessage.QueryString != null) + { + webClient.QueryString = requestMessage.QueryString; + } + + switch (Credentials) + { + case RedmineApiKeyAuthentication: + webClient.Headers.Add(RedmineConstants.API_KEY_AUTHORIZATION_HEADER_KEY,Credentials.Token); + break; + case RedmineBasicAuthentication: + webClient.Headers.Add(RedmineConstants.AUTHORIZATION_HEADER_KEY, Credentials.Token); + break; + } + + if (!requestMessage.ImpersonateUser.IsNullOrWhiteSpace()) + { + webClient.Headers.Add(RedmineConstants.IMPERSONATE_HEADER_KEY, requestMessage.ImpersonateUser); + } + } + + private static byte[] DownloadWithProgress(string url, System.Net.WebClient webClient, IProgress progress) + { + var contentLength = GetContentLength(webClient); + byte[] data; + if (contentLength > 0) + { + using (var respStream = webClient.OpenRead(url)) + { + data = new byte[contentLength]; + var buffer = new byte[4096]; + int bytesRead; + var totalBytesRead = 0; + + while ((bytesRead = respStream.Read(buffer, 0, buffer.Length)) > 0) + { + Buffer.BlockCopy(buffer, 0, data, totalBytesRead, bytesRead); + totalBytesRead += bytesRead; + + ReportProgress(progress, contentLength, totalBytesRead); + } + } + } + else + { + data = webClient.DownloadData(url); + progress?.Report(100); + } + + return data; + } + + private static int GetContentLength(System.Net.WebClient webClient) + { + var total = -1; + if (webClient.ResponseHeaders == null) + { + return total; + } + + var contentLengthAsString = webClient.ResponseHeaders[HttpRequestHeader.ContentLength]; + total = Convert.ToInt32(contentLengthAsString, CultureInfo.InvariantCulture); + + return total; + } + + /// + /// Handles the web exception. + /// + /// The exception. + /// + /// Timeout! + /// Bad domain name! + /// + /// + /// + /// + /// The page that you are trying to update is staled! + /// + /// + public static void HandleWebException(WebException exception, IRedmineSerializer serializer) + { + if (exception == null) + { + return; + } + + var innerException = exception.InnerException ?? exception; + + switch (exception.Status) + { + case WebExceptionStatus.Timeout: + throw new RedmineTimeoutException(nameof(WebExceptionStatus.Timeout), innerException); + case WebExceptionStatus.NameResolutionFailure: + throw new NameResolutionFailureException("Bad domain name.", innerException); + case WebExceptionStatus.ProtocolError: + if (exception.Response != null) + { + var statusCode = exception.Response is HttpWebResponse httpResponse + ? (int)httpResponse.StatusCode + : (int)HttpStatusCode.InternalServerError; + + using var responseStream = exception.Response.GetResponseStream(); + RedmineExceptionHelper.MapStatusCodeToException(statusCode, responseStream, innerException, serializer); + } + + break; + } + throw new RedmineException(exception.Message, innerException); + } + } +} diff --git a/src/redmine-net-api/Net/WebClient/InternalWebClient.cs b/src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs similarity index 79% rename from src/redmine-net-api/Net/WebClient/InternalWebClient.cs rename to src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs index 2bec6d92..fabd7219 100644 --- a/src/redmine-net-api/Net/WebClient/InternalWebClient.cs +++ b/src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs @@ -13,16 +13,17 @@ You may obtain a copy of the License at See the License for the specific language governing permissions and limitations under the License. */ + using System; using System.Net; using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Options; -namespace Redmine.Net.Api.Net.WebClient; +namespace Redmine.Net.Api.Http.Clients.WebClient; internal sealed class InternalWebClient : System.Net.WebClient { - private readonly IRedmineWebClientOptions _webClientOptions; + private readonly RedmineWebClientOptions _webClientOptions; #pragma warning disable SYSLIB0014 public InternalWebClient(RedmineManagerOptions redmineManagerOptions) @@ -43,9 +44,9 @@ protected override WebRequest GetWebRequest(Uri address) return base.GetWebRequest(address); } - httpWebRequest.UserAgent = _webClientOptions.UserAgent.ValueOrFallback("RedmineDotNetAPIClient"); + httpWebRequest.UserAgent = _webClientOptions.UserAgent; - httpWebRequest.AutomaticDecompression = _webClientOptions.DecompressionFormat ?? DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.None; + AssignIfHasValue(_webClientOptions.DecompressionFormat, value => httpWebRequest.AutomaticDecompression = value); AssignIfHasValue(_webClientOptions.AutoRedirect, value => httpWebRequest.AllowAutoRedirect = value); @@ -78,14 +79,14 @@ protected override WebRequest GetWebRequest(Uri address) httpWebRequest.Credentials = _webClientOptions.Credentials; - #if NET40_OR_GREATER || NETCOREAPP + #if NET40_OR_GREATER || NET if (_webClientOptions.ClientCertificates != null) { httpWebRequest.ClientCertificates = _webClientOptions.ClientCertificates; } #endif - #if (NET45_OR_GREATER || NETCOREAPP) + #if (NET45_OR_GREATER || NET) httpWebRequest.ServerCertificateValidationCallback = _webClientOptions.ServerCertificateValidationCallback; #endif @@ -101,6 +102,28 @@ protected override WebRequest GetWebRequest(Uri address) throw new RedmineException(webException.GetBaseException().Message, webException); } } + + public HttpStatusCode StatusCode { get; private set; } + + protected override WebResponse GetWebResponse(WebRequest request) + { + var response = base.GetWebResponse(request); + if (response is HttpWebResponse httpResponse) + { + StatusCode = httpResponse.StatusCode; + } + return response; + } + + protected override WebResponse GetWebResponse(WebRequest request, IAsyncResult result) + { + var response = base.GetWebResponse(request, result); + if (response is HttpWebResponse httpResponse) + { + StatusCode = httpResponse.StatusCode; + } + return response; + } private static void AssignIfHasValue(T? nullableValue, Action assignAction) where T : struct { diff --git a/src/redmine-net-api/Http/Clients/WebClient/RedmineApiRequestContent.cs b/src/redmine-net-api/Http/Clients/WebClient/RedmineApiRequestContent.cs new file mode 100644 index 00000000..9d9c33f0 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/RedmineApiRequestContent.cs @@ -0,0 +1,93 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Text; +using Redmine.Net.Api.Http.Constants; + +namespace Redmine.Net.Api.Http.Clients.WebClient; + +internal class RedmineApiRequestContent : IDisposable +{ + private static readonly byte[] _emptyByteArray = []; + private bool _isDisposed; + + /// + /// Gets the content type of the request. + /// + public string ContentType { get; } + + /// + /// Gets the body of the request. + /// + public byte[] Body { get; } + + /// + /// Gets the length of the request body. + /// + public int Length => Body?.Length ?? 0; + + /// + /// Creates a new instance of RedmineApiRequestContent. + /// + /// The content type of the request. + /// The body of the request. + /// Thrown when the contentType is null. + public RedmineApiRequestContent(string contentType, byte[] body) + { + ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType)); + Body = body ?? _emptyByteArray; + } + + /// + /// Creates a text-based request content with the specified MIME type. + /// + /// The text content. + /// The MIME type of the content. + /// The encoding to use (defaults to UTF-8). + /// A new RedmineApiRequestContent instance. + public static RedmineApiRequestContent CreateString(string text, string mimeType, Encoding encoding = null) + { + if (string.IsNullOrEmpty(text)) + { + return new RedmineApiRequestContent(mimeType, _emptyByteArray); + } + + encoding ??= Encoding.UTF8; + return new RedmineApiRequestContent(mimeType, encoding.GetBytes(text)); + } + + /// + /// Creates a binary request content. + /// + /// The binary data. + /// A new RedmineApiRequestContent instance. + public static RedmineApiRequestContent CreateBinary(byte[] data) + { + return new RedmineApiRequestContent(HttpConstants.ContentTypes.ApplicationOctetStream, data); + } + + /// + /// Disposes the resources used by this instance. + /// + public void Dispose() + { + if (!_isDisposed) + { + _isDisposed = true; + } + } +} diff --git a/src/redmine-net-api/Http/Clients/WebClient/RedmineWebClientOptions.cs b/src/redmine-net-api/Http/Clients/WebClient/RedmineWebClientOptions.cs new file mode 100644 index 00000000..013d5a82 --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/RedmineWebClientOptions.cs @@ -0,0 +1,83 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System.Net; +#if (NET45_OR_GREATER || NET) +using System.Net.Security; +#endif +using System.Security.Cryptography.X509Certificates; + +namespace Redmine.Net.Api.Http.Clients.WebClient; +/// +/// +/// +public sealed class RedmineWebClientOptions: RedmineApiClientOptions +{ + + /// + /// + /// + public bool? KeepAlive { get; set; } + + /// + /// + /// + public bool? UnsafeAuthenticatedConnectionSharing { get; set; } + + /// + /// + /// + public int? DefaultConnectionLimit { get; set; } + + /// + /// + /// + public int? DnsRefreshTimeout { get; set; } + + /// + /// + /// + public bool? EnableDnsRoundRobin { get; set; } + + /// + /// + /// + public int? MaxServicePoints { get; set; } + + /// + /// + /// + public int? MaxServicePointIdleTime { get; set; } + + #if(NET46_OR_GREATER || NET) + /// + /// + /// + public bool? ReusePort { get; set; } + #endif + + /// + /// + /// + public SecurityProtocolType? SecurityProtocolType { get; set; } + +#if (NET45_OR_GREATER || NET) + /// + /// + /// + public RemoteCertificateValidationCallback ServerCertificateValidationCallback { get; set; } + #endif +} \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/WebClient/WebClientExtensions.cs b/src/redmine-net-api/Http/Clients/WebClient/WebClientExtensions.cs new file mode 100644 index 00000000..ceb2a2cc --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/WebClientExtensions.cs @@ -0,0 +1,32 @@ +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Serialization; + +namespace Redmine.Net.Api.Http.Clients.WebClient; + +internal static class WebClientExtensions +{ + public static void ApplyHeaders(this System.Net.WebClient client, RequestOptions options, IRedmineSerializer serializer) + { + client.Headers.Add(RedmineConstants.CONTENT_TYPE_HEADER_KEY, options.ContentType ?? serializer.ContentType); + + if (!options.UserAgent.IsNullOrWhiteSpace()) + { + client.Headers.Add(RedmineConstants.USER_AGENT_HEADER_KEY, options.UserAgent); + } + + if (!options.ImpersonateUser.IsNullOrWhiteSpace()) + { + client.Headers.Add(RedmineConstants.IMPERSONATE_HEADER_KEY, options.ImpersonateUser); + } + + if (options.Headers is not { Count: > 0 }) + { + return; + } + + foreach (var header in options.Headers) + { + client.Headers.Add(header.Key, header.Value); + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Http/Clients/WebClient/WebClientProvider.cs b/src/redmine-net-api/Http/Clients/WebClient/WebClientProvider.cs new file mode 100644 index 00000000..ff8c664b --- /dev/null +++ b/src/redmine-net-api/Http/Clients/WebClient/WebClientProvider.cs @@ -0,0 +1,67 @@ +using System; +using System.Net; +using System.Text; +using Redmine.Net.Api.Options; + +namespace Redmine.Net.Api.Http.Clients.WebClient; + +internal static class WebClientProvider +{ + /// + /// Creates a new WebClient instance with the specified options. + /// + /// The options for the Redmine manager. + /// A new WebClient instance. + public static System.Net.WebClient CreateWebClient(RedmineManagerOptions options) + { + var webClient = new InternalWebClient(options); + + if (options?.ApiClientOptions is RedmineWebClientOptions webClientOptions) + { + ConfigureWebClient(webClient, webClientOptions); + } + + return webClient; + } + + /// + /// Configures a WebClient instance with the specified options. + /// + /// The WebClient instance to configure. + /// The options to apply. + private static void ConfigureWebClient(System.Net.WebClient webClient, RedmineWebClientOptions options) + { + if (options == null) return; + + webClient.Proxy = options.Proxy; + webClient.Headers = null; + webClient.BaseAddress = null; + webClient.CachePolicy = null; + webClient.Credentials = null; + webClient.Encoding = Encoding.UTF8; + webClient.UseDefaultCredentials = false; + + // if (options.Timeout.HasValue && options.Timeout.Value != TimeSpan.Zero) + // { + // webClient.Timeout = options.Timeout; + // } + // + // if (options.KeepAlive.HasValue) + // { + // webClient.KeepAlive = options.KeepAlive.Value; + // } + // + // if (options.UnsafeAuthenticatedConnectionSharing.HasValue) + // { + // webClient.UnsafeAuthenticatedConnectionSharing = options.UnsafeAuthenticatedConnectionSharing.Value; + // } + // + // #if NET40_OR_GREATER || NET + // if (options.ClientCertificates != null) + // { + // webClient.ClientCertificates = options.ClientCertificates; + // } + // #endif + + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Http/Constants/HttpConstants.cs b/src/redmine-net-api/Http/Constants/HttpConstants.cs new file mode 100644 index 00000000..d1666c4b --- /dev/null +++ b/src/redmine-net-api/Http/Constants/HttpConstants.cs @@ -0,0 +1,106 @@ +namespace Redmine.Net.Api.Http.Constants; + +/// +/// +/// +public static class HttpConstants +{ + /// + /// HTTP status codes including custom codes used by Redmine. + /// + internal static class StatusCodes + { + public const int Unauthorized = 401; + public const int Forbidden = 403; + public const int NotFound = 404; + public const int NotAcceptable = 406; + public const int RequestTimeout = 408; + public const int Conflict = 409; + public const int UnprocessableEntity = 422; + public const int TooManyRequests = 429; + public const int InternalServerError = 500; + public const int BadGateway = 502; + public const int ServiceUnavailable = 503; + public const int GatewayTimeout = 504; + } + + /// + /// Standard HTTP headers used in API requests and responses. + /// + internal static class Headers + { + public const string Authorization = "Authorization"; + public const string ApiKey = "X-Redmine-API-Key"; + public const string Impersonate = "X-Redmine-Switch-User"; + public const string ContentType = "Content-Type"; + } + + internal static class Names + { + /// HTTP User-Agent header name. + public static string UserAgent => "User-Agent"; + } + + internal static class Values + { + /// User agent string to use for all HTTP requests. + public static string UserAgent => "Redmine-NET-API"; + } + + /// + /// MIME content types used in API requests and responses. + /// + internal static class ContentTypes + { + public const string ApplicationJson = "application/json"; + public const string ApplicationXml = "application/xml"; + public const string ApplicationOctetStream = "application/octet-stream"; + } + + /// + /// Error messages for different HTTP status codes. + /// + internal static class ErrorMessages + { + public const string NotFound = "The requested resource was not found."; + public const string Unauthorized = "Authentication is required or has failed."; + public const string Forbidden = "You don't have permission to access this resource."; + public const string Conflict = "The resource you are trying to update has been modified since you last retrieved it."; + public const string NotAcceptable = "The requested format is not supported."; + public const string InternalServerError = "The server encountered an unexpected error."; + public const string UnprocessableEntity = "Validation failed for the submitted data."; + public const string Cancelled = "The operation was cancelled."; + public const string TimedOut = "The operation has timed out."; + } + + /// + /// + /// + internal static class HttpVerbs + { + /// + /// Represents an HTTP GET protocol method that is used to get an entity identified by a URI. + /// + public const string GET = "GET"; + /// + /// Represents an HTTP PUT protocol method that is used to replace an entity identified by a URI. + /// + public const string PUT = "PUT"; + /// + /// Represents an HTTP POST protocol method that is used to post a new entity as an addition to a URI. + /// + public const string POST = "POST"; + /// + /// Represents an HTTP PATCH protocol method that is used to patch an existing entity identified by a URI. + /// + public const string PATCH = "PATCH"; + /// + /// Represents an HTTP DELETE protocol method that is used to delete an existing entity identified by a URI. + /// + public const string DELETE = "DELETE"; + + internal const string DOWNLOAD = "DOWNLOAD"; + + internal const string UPLOAD = "UPLOAD"; + } +} diff --git a/src/redmine-net-api/Http/Extensions/NameValueCollectionExtensions.cs b/src/redmine-net-api/Http/Extensions/NameValueCollectionExtensions.cs new file mode 100644 index 00000000..3cf95a1d --- /dev/null +++ b/src/redmine-net-api/Http/Extensions/NameValueCollectionExtensions.cs @@ -0,0 +1,268 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using Redmine.Net.Api.Extensions; + +namespace Redmine.Net.Api.Http.Extensions +{ + /// + /// + /// + public static class NameValueCollectionExtensions + { + /// + /// Gets the parameter value. + /// + /// The parameters. + /// Name of the parameter. + /// + public static string GetParameterValue(this NameValueCollection parameters, string parameterName) + { + return GetValue(parameters, parameterName); + } + + /// + /// Gets the parameter value. + /// + /// The parameters. + /// Name of the parameter. + /// + public static string GetValue(this NameValueCollection parameters, string key) + { + if (parameters == null) + { + return null; + } + + var value = parameters.Get(key); + + return value.IsNullOrWhiteSpace() ? null : value; + } + + /// + /// + /// + /// + /// + public static string ToQueryString(this NameValueCollection requestParameters) + { + if (requestParameters == null || requestParameters.Count == 0) + { + return null; + } + + var delimiter = string.Empty; + + var stringBuilder = new StringBuilder(); + + for (var index = 0; index < requestParameters.Count; ++index) + { + stringBuilder + .Append(delimiter) + .Append(requestParameters.AllKeys[index].ToString(CultureInfo.InvariantCulture)) + .Append('=') + .Append(requestParameters[index].ToString(CultureInfo.InvariantCulture)); + delimiter = "&"; + } + + var queryString = stringBuilder.ToString(); + + stringBuilder.Length = 0; + + return queryString; + } + + internal static NameValueCollection AddPagingParameters(this NameValueCollection parameters, int pageSize, int offset) + { + parameters ??= new NameValueCollection(); + + if(pageSize <= 0) + { + pageSize = RedmineConstants.DEFAULT_PAGE_SIZE_VALUE; + } + + if(offset < 0) + { + offset = 0; + } + + parameters.Set(RedmineKeys.LIMIT, pageSize.ToInvariantString()); + parameters.Set(RedmineKeys.OFFSET, offset.ToInvariantString()); + + return parameters; + } + + internal static NameValueCollection AddParamsIfExist(this NameValueCollection parameters, string[] include) + { + if (include is not {Length: > 0}) + { + return parameters; + } + + parameters ??= new NameValueCollection(); + + parameters.Add(RedmineKeys.INCLUDE, string.Join(",", include)); + + return parameters; + } + + internal static void AddIfNotNull(this NameValueCollection nameValueCollection, string key, string value) + { + if (!value.IsNullOrWhiteSpace()) + { + nameValueCollection.Add(key, value); + } + } + + internal static void AddIfNotNull(this NameValueCollection nameValueCollection, string key, bool? value) + { + if (value.HasValue) + { + nameValueCollection.Add(key, value.Value.ToInvariantString()); + } + } + + /// + /// Creates a new NameValueCollection with an initial key-value pair. + /// + /// The key for the first item. + /// The value for the first item. + /// A new NameValueCollection containing the specified key-value pair. + public static NameValueCollection WithItem(this string key, string value) + { + var collection = new NameValueCollection(); + collection.Add(key, value); + return collection; + } + + /// + /// Adds a new key-value pair to an existing NameValueCollection and returns the collection for chaining. + /// + /// The NameValueCollection to add to. + /// The key to add. + /// The value to add. + /// The NameValueCollection with the new key-value pair added. + public static NameValueCollection AndItem(this NameValueCollection collection, string key, string value) + { + collection.Add(key, value); + return collection; + } + + /// + /// Adds a new key-value pair to an existing NameValueCollection if the condition is true. + /// + /// The NameValueCollection to add to. + /// The condition to evaluate. + /// The key to add if condition is true. + /// The value to add if condition is true. + /// The NameValueCollection, potentially with a new key-value pair added. + public static NameValueCollection AndItemIf(this NameValueCollection collection, bool condition, string key, string value) + { + if (condition) + { + collection.Add(key, value); + } + return collection; + } + + /// + /// Adds a new key-value pair to an existing NameValueCollection if the value is not null. + /// + /// The NameValueCollection to add to. + /// The key to add if value is not null. + /// The value to check and add. + /// The NameValueCollection, potentially with a new key-value pair added. + public static NameValueCollection AndItemIfNotNull(this NameValueCollection collection, string key, string value) + { + if (value != null) + { + collection.Add(key, value); + } + return collection; + } + + /// + /// Creates a new NameValueCollection with an initial key-value pair where the value is converted from an integer. + /// + /// The key for the first item. + /// The integer value to be converted to string. + /// A new NameValueCollection containing the specified key-value pair. + public static NameValueCollection WithInt(this string key, int value) + { + return key.WithItem(value.ToInvariantString()); + } + + /// + /// Adds a new key-value pair to an existing NameValueCollection where the value is converted from an integer. + /// + /// The NameValueCollection to add to. + /// The key to add. + /// The integer value to be converted to string. + /// The NameValueCollection with the new key-value pair added. + public static NameValueCollection AndInt(this NameValueCollection collection, string key, int value) + { + return collection.AndItem(key, value.ToInvariantString()); + } + + + /// + /// Converts a NameValueCollection to a Dictionary. + /// + /// The collection to convert. + /// A new Dictionary containing the collection's key-value pairs. + public static Dictionary ToDictionary(this NameValueCollection collection) + { + var dict = new Dictionary(); + + if (collection != null) + { + foreach (string key in collection.Keys) + { + dict[key] = collection[key]; + } + } + + return dict; + } + + /// + /// Creates a new NameValueCollection from a dictionary of key-value pairs. + /// + /// Dictionary of key-value pairs to add to the collection. + /// A new NameValueCollection containing the specified key-value pairs. + public static NameValueCollection ToNameValueCollection(this Dictionary keyValuePairs) + { + var collection = new NameValueCollection(); + + if (keyValuePairs != null) + { + foreach (var pair in keyValuePairs) + { + collection.Add(pair.Key, pair.Value); + } + } + + return collection; + } + + + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Http/Extensions/RedmineApiResponseExtensions.cs b/src/redmine-net-api/Http/Extensions/RedmineApiResponseExtensions.cs new file mode 100644 index 00000000..e03d3ed5 --- /dev/null +++ b/src/redmine-net-api/Http/Extensions/RedmineApiResponseExtensions.cs @@ -0,0 +1,55 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System.Collections.Generic; +using System.Text; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http.Messages; +using Redmine.Net.Api.Serialization; + +namespace Redmine.Net.Api.Http.Extensions; + +internal static class RedmineApiResponseExtensions +{ + internal static T DeserializeTo(this RedmineApiResponse responseMessage, IRedmineSerializer redmineSerializer) where T : new() + { + var responseAsString = GetResponseContentAsString(responseMessage); + return responseAsString.IsNullOrWhiteSpace() ? default : redmineSerializer.Deserialize(responseAsString); + } + + internal static PagedResults DeserializeToPagedResults(this RedmineApiResponse responseMessage, IRedmineSerializer redmineSerializer) where T : class, new() + { + var responseAsString = GetResponseContentAsString(responseMessage); + return responseAsString.IsNullOrWhiteSpace() ? default : redmineSerializer.DeserializeToPagedResults(responseAsString); + } + + internal static List DeserializeToList(this RedmineApiResponse responseMessage, IRedmineSerializer redmineSerializer) where T : class, new() + { + var responseAsString = GetResponseContentAsString(responseMessage); + return responseAsString.IsNullOrWhiteSpace() ? null : redmineSerializer.Deserialize>(responseAsString); + } + + /// + /// Gets the response content as a UTF-8 encoded string. + /// + /// The API response message. + /// The content as a string, or null if the response or content is null. + private static string GetResponseContentAsString(RedmineApiResponse responseMessage) + { + return responseMessage?.Content == null ? null : Encoding.UTF8.GetString(responseMessage.Content); + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Http/Helpers/RedmineExceptionHelper.cs b/src/redmine-net-api/Http/Helpers/RedmineExceptionHelper.cs new file mode 100644 index 00000000..4e0aa252 --- /dev/null +++ b/src/redmine-net-api/Http/Helpers/RedmineExceptionHelper.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http.Constants; +using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Types; + +namespace Redmine.Net.Api.Http.Helpers; + +/// +/// Handles HTTP status codes and converts them to appropriate Redmine exceptions. +/// +internal static class RedmineExceptionHelper +{ + /// + /// Maps an HTTP status code to an appropriate Redmine exception. + /// + /// The HTTP status code. + /// The response stream containing error details. + /// The inner exception, if any. + /// The serializer to use for deserializing error messages. + /// A specific Redmine exception based on the status code. + internal static void MapStatusCodeToException(int statusCode, Stream responseStream, Exception inner, IRedmineSerializer serializer) + { + switch (statusCode) + { + case HttpConstants.StatusCodes.NotFound: + throw new NotFoundException(HttpConstants.ErrorMessages.NotFound, inner); + + case HttpConstants.StatusCodes.Unauthorized: + throw new UnauthorizedException(HttpConstants.ErrorMessages.Unauthorized, inner); + + case HttpConstants.StatusCodes.Forbidden: + throw new ForbiddenException(HttpConstants.ErrorMessages.Forbidden, inner); + + case HttpConstants.StatusCodes.Conflict: + throw new ConflictException(HttpConstants.ErrorMessages.Conflict, inner); + + case HttpConstants.StatusCodes.UnprocessableEntity: + throw CreateUnprocessableEntityException(responseStream, inner, serializer); + + case HttpConstants.StatusCodes.NotAcceptable: + throw new NotAcceptableException(HttpConstants.ErrorMessages.NotAcceptable, inner); + + case HttpConstants.StatusCodes.InternalServerError: + throw new InternalServerErrorException(HttpConstants.ErrorMessages.InternalServerError, inner); + + default: + throw new RedmineException($"HTTP {statusCode} – {(HttpStatusCode)statusCode}", inner); + } + } + + /// + /// Creates an exception for a 422 Unprocessable Entity response. + /// + /// The response stream containing error details. + /// The inner exception, if any. + /// The serializer to use for deserializing error messages. + /// A RedmineException with details about the validation errors. + private static RedmineException CreateUnprocessableEntityException(Stream responseStream, Exception inner, IRedmineSerializer serializer) + { + var errors = GetRedmineErrors(responseStream, serializer); + + if (errors is null) + { + return new RedmineException(HttpConstants.ErrorMessages.UnprocessableEntity, inner); + } + + var sb = new StringBuilder(); + foreach (var error in errors) + { + sb.Append(error.Info); + sb.Append(Environment.NewLine); + } + + if (sb.Length > 0) + { + sb.Length -= 1; + } + + return new RedmineException($"Unprocessable Content: {sb}", inner); + } + + /// + /// Gets the Redmine errors from a response stream. + /// + /// The response stream containing error details. + /// The serializer to use for deserializing error messages. + /// A list of error objects or null if unable to parse errors. + private static List GetRedmineErrors(Stream responseStream, IRedmineSerializer serializer) + { + if (responseStream == null) + { + return null; + } + + using (responseStream) + { + try + { + using var reader = new StreamReader(responseStream); + var content = reader.ReadToEnd(); + return GetRedmineErrors(content, serializer); + } + catch(Exception ex) + { + throw new RedmineApiException(ex.Message, ex); + } + } + } + + /// + /// Gets the Redmine errors from response content. + /// + /// The response content as a string. + /// The serializer to use for deserializing error messages. + /// A list of error objects or null if unable to parse errors. + private static List GetRedmineErrors(string content, IRedmineSerializer serializer) + { + if (content.IsNullOrWhiteSpace()) + { + return null; + } + + try + { + var paged = serializer.DeserializeToPagedResults(content); + return (List)paged.Items; + } + catch(Exception ex) + { + throw new RedmineException(ex.Message, ex); + } + } +} diff --git a/src/redmine-net-api/Http/Helpers/RedmineHttpMethodHelper.cs b/src/redmine-net-api/Http/Helpers/RedmineHttpMethodHelper.cs new file mode 100644 index 00000000..c3fc7e10 --- /dev/null +++ b/src/redmine-net-api/Http/Helpers/RedmineHttpMethodHelper.cs @@ -0,0 +1,63 @@ +using System; +using Redmine.Net.Api.Http.Constants; +#if !NET20 +using System.Net.Http; +#endif + +namespace Redmine.Net.Api.Http.Helpers; + +internal static class RedmineHttpMethodHelper +{ +#if !NET20 + private static readonly HttpMethod PatchMethod = new HttpMethod("PATCH"); + private static readonly HttpMethod DownloadMethod = new HttpMethod("DOWNLOAD"); + + /// + /// Gets an HttpMethod instance for the specified HTTP verb. + /// + /// The HTTP verb (GET, POST, etc.). + /// An HttpMethod instance corresponding to the verb. + /// Thrown when the verb is not supported. + public static HttpMethod GetHttpMethod(string verb) + { + return verb switch + { + HttpConstants.HttpVerbs.GET => HttpMethod.Get, + HttpConstants.HttpVerbs.POST => HttpMethod.Post, + HttpConstants.HttpVerbs.PUT => HttpMethod.Put, + HttpConstants.HttpVerbs.PATCH => PatchMethod, + HttpConstants.HttpVerbs.DELETE => HttpMethod.Delete, + HttpConstants.HttpVerbs.DOWNLOAD => DownloadMethod, + _ => throw new ArgumentException($"Unsupported HTTP verb: {verb}") + }; + } +#endif + /// + /// Determines whether the specified HTTP method is a GET or DOWNLOAD method. + /// + /// The HTTP method to check. + /// True if the method is GET or DOWNLOAD; otherwise, false. + public static bool IsGetOrDownload(string method) + { + return method == HttpConstants.HttpVerbs.GET || method == HttpConstants.HttpVerbs.DOWNLOAD; + } + + /// + /// Determines whether the HTTP status code represents a transient error. + /// + /// The HTTP response status code. + /// True if the status code represents a transient error; otherwise, false. + private static bool IsTransientError(int statusCode) + { + return statusCode switch + { + HttpConstants.StatusCodes.BadGateway => true, + HttpConstants.StatusCodes.GatewayTimeout => true, + HttpConstants.StatusCodes.ServiceUnavailable => true, + HttpConstants.StatusCodes.RequestTimeout => true, + HttpConstants.StatusCodes.TooManyRequests => true, + _ => false + }; + } + +} diff --git a/src/redmine-net-api/Http/IRedmineApiClient.cs b/src/redmine-net-api/Http/IRedmineApiClient.cs new file mode 100644 index 00000000..203d9599 --- /dev/null +++ b/src/redmine-net-api/Http/IRedmineApiClient.cs @@ -0,0 +1,75 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using Redmine.Net.Api.Http.Messages; +#if !NET20 +using System.Threading; +using System.Threading.Tasks; +#endif +using Redmine.Net.Api.Net; +using Redmine.Net.Api.Net.Internal; + +namespace Redmine.Net.Api.Http; +/// +/// +/// +internal interface IRedmineApiClient : ISyncRedmineApiClient +#if !NET20 + , IAsyncRedmineApiClient +#endif +{ +} + +internal interface ISyncRedmineApiClient +{ + RedmineApiResponse Get(string address, RequestOptions requestOptions = null); + + RedmineApiResponse GetPaged(string address, RequestOptions requestOptions = null); + + RedmineApiResponse Create(string address, string payload, RequestOptions requestOptions = null); + + RedmineApiResponse Update(string address, string payload, RequestOptions requestOptions = null); + + RedmineApiResponse Patch(string address, string payload, RequestOptions requestOptions = null); + + RedmineApiResponse Delete(string address, RequestOptions requestOptions = null); + + RedmineApiResponse Upload(string address, byte[] data, RequestOptions requestOptions = null); + + RedmineApiResponse Download(string address, RequestOptions requestOptions = null, IProgress progress = null); +} + +#if !NET20 +internal interface IAsyncRedmineApiClient +{ + Task GetAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetPagedAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task CreateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task UpdateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task PatchAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task DeleteAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task UploadFileAsync(string address, byte[] data, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task DownloadAsync(string address, RequestOptions requestOptions = null, IProgress progress = null, CancellationToken cancellationToken = default); +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/IRedmineApiClientOptions.cs b/src/redmine-net-api/Http/IRedmineApiClientOptions.cs new file mode 100644 index 00000000..44089261 --- /dev/null +++ b/src/redmine-net-api/Http/IRedmineApiClientOptions.cs @@ -0,0 +1,148 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Cache; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace Redmine.Net.Api.Http +{ + /// + /// + /// + public interface IRedmineApiClientOptions + { + /// + /// + /// + bool? AutoRedirect { get; set; } + + /// + /// + /// + CookieContainer CookieContainer { get; set; } + + /// + /// + /// + DecompressionMethods? DecompressionFormat { get; set; } + + /// + /// + /// + ICredentials Credentials { get; set; } + + /// + /// + /// + Dictionary DefaultHeaders { get; set; } + + /// + /// + /// + IWebProxy Proxy { get; set; } + + /// + /// + /// + int? MaxAutomaticRedirections { get; set; } + +#if NET471_OR_GREATER || NET + /// + /// + /// + int? MaxConnectionsPerServer { get; set; } + + /// + /// + /// + int? MaxResponseHeadersLength { get; set; } +#endif + /// + /// + /// + bool? PreAuthenticate { get; set; } + + /// + /// + /// + RequestCachePolicy RequestCachePolicy { get; set; } + + /// + /// + /// + string Scheme { get; set; } + + /// + /// + /// + TimeSpan? Timeout { get; set; } + + /// + /// + /// + string UserAgent { get; set; } + + /// + /// + /// + bool? UseCookies { get; set; } + +#if NETFRAMEWORK + + /// + /// + /// + bool CheckCertificateRevocationList { get; set; } + + /// + /// + /// + long? MaxRequestContentBufferSize { get; set; } + + /// + /// + /// + long? MaxResponseContentBufferSize { get; set; } + + /// + /// + /// + bool? UseDefaultCredentials { get; set; } +#endif + /// + /// + /// + bool? UseProxy { get; set; } + + /// + /// + /// + /// Only HTTP/1.0 and HTTP/1.1 version requests are currently supported. + Version ProtocolVersion { get; set; } + + +#if NET40_OR_GREATER || NET + /// + /// + /// + public X509CertificateCollection ClientCertificates { get; set; } +#endif + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Net/ApiRequestMessage.cs b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs similarity index 74% rename from src/redmine-net-api/Net/ApiRequestMessage.cs rename to src/redmine-net-api/Http/Messages/RedmineApiRequest.cs index b0b7a2fb..ad42592d 100644 --- a/src/redmine-net-api/Net/ApiRequestMessage.cs +++ b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs @@ -15,13 +15,15 @@ limitations under the License. */ using System.Collections.Specialized; +using Redmine.Net.Api.Http.Clients.WebClient; +using Redmine.Net.Api.Http.Constants; -namespace Redmine.Net.Api.Net; +namespace Redmine.Net.Api.Http.Messages; -internal sealed class ApiRequestMessage +internal sealed class RedmineApiRequest { - public ApiRequestMessageContent Content { get; set; } - public string Method { get; set; } = HttpVerbs.GET; + public RedmineApiRequestContent Content { get; set; } + public string Method { get; set; } = HttpConstants.HttpVerbs.GET; public string RequestUri { get; set; } public NameValueCollection QueryString { get; set; } public string ImpersonateUser { get; set; } diff --git a/src/redmine-net-api/Net/ApiResponseMessage.cs b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs similarity index 82% rename from src/redmine-net-api/Net/ApiResponseMessage.cs rename to src/redmine-net-api/Http/Messages/RedmineApiResponse.cs index 971aaabb..71fb1948 100644 --- a/src/redmine-net-api/Net/ApiResponseMessage.cs +++ b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs @@ -15,11 +15,15 @@ limitations under the License. */ using System.Collections.Specialized; +using System.Net; -namespace Redmine.Net.Api.Net; +namespace Redmine.Net.Api.Http.Messages; -internal sealed class ApiResponseMessage +internal sealed class RedmineApiResponse { public NameValueCollection Headers { get; init; } public byte[] Content { get; init; } + + public HttpStatusCode StatusCode { get; init; } + } \ No newline at end of file diff --git a/src/redmine-net-api/Net/RedirectType.cs b/src/redmine-net-api/Http/RedirectType.cs similarity index 93% rename from src/redmine-net-api/Net/RedirectType.cs rename to src/redmine-net-api/Http/RedirectType.cs index ae9aedb5..5bb7acf7 100644 --- a/src/redmine-net-api/Net/RedirectType.cs +++ b/src/redmine-net-api/Http/RedirectType.cs @@ -14,12 +14,12 @@ You may obtain a copy of the License at limitations under the License. */ -namespace Redmine.Net.Api +namespace Redmine.Net.Api.Http { /// /// /// - public enum RedirectType + internal enum RedirectType { /// /// diff --git a/src/redmine-net-api/Http/RedmineApiClient.Async.cs b/src/redmine-net-api/Http/RedmineApiClient.Async.cs new file mode 100644 index 00000000..e30fb302 --- /dev/null +++ b/src/redmine-net-api/Http/RedmineApiClient.Async.cs @@ -0,0 +1,77 @@ +#if !NET20 +using System; +using System.Threading; +using System.Threading.Tasks; +using Redmine.Net.Api.Http.Constants; +using Redmine.Net.Api.Http.Messages; +using Redmine.Net.Api.Net; +using Redmine.Net.Api.Net.Internal; + +namespace Redmine.Net.Api.Http; + +internal abstract partial class RedmineApiClient +{ + public async Task GetAsync(string address, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.GET, requestOptions, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + public async Task GetPagedAsync(string address, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + return await GetAsync(address, requestOptions, cancellationToken).ConfigureAwait(false); + } + + public async Task CreateAsync(string address, string payload, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.POST, requestOptions, CreateContentFromPayload(payload), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpdateAsync(string address, string payload, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.PUT, requestOptions, CreateContentFromPayload(payload), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UploadFileAsync(string address, byte[] data, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.POST, requestOptions, CreateContentFromBytes(data), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task PatchAsync(string address, string payload, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.PATCH, requestOptions, CreateContentFromPayload(payload), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteAsync(string address, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.DELETE, requestOptions, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + public async Task DownloadAsync(string address, RequestOptions requestOptions = null, + IProgress progress = null, CancellationToken cancellationToken = default) + { + return await HandleRequestAsync(address, HttpConstants.HttpVerbs.DOWNLOAD, requestOptions, progress: progress, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + protected abstract Task HandleRequestAsync( + string address, + string verb, + RequestOptions requestOptions = null, + object content = null, + IProgress progress = null, + CancellationToken cancellationToken = default); +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Http/RedmineApiClient.cs b/src/redmine-net-api/Http/RedmineApiClient.cs new file mode 100644 index 00000000..c105e59e --- /dev/null +++ b/src/redmine-net-api/Http/RedmineApiClient.cs @@ -0,0 +1,113 @@ +using System; +using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Http.Constants; +using Redmine.Net.Api.Http.Messages; +using Redmine.Net.Api.Net; +using Redmine.Net.Api.Net.Internal; +using Redmine.Net.Api.Options; +using Redmine.Net.Api.Serialization; + +namespace Redmine.Net.Api.Http; + +internal abstract partial class RedmineApiClient : IRedmineApiClient +{ + protected readonly IRedmineAuthentication Credentials; + protected readonly IRedmineSerializer Serializer; + protected readonly RedmineManagerOptions Options; + + protected RedmineApiClient(RedmineManagerOptions redmineManagerOptions) + { + Credentials = redmineManagerOptions.Authentication; + Serializer = redmineManagerOptions.Serializer; + Options = redmineManagerOptions; + } + + public RedmineApiResponse Get(string address, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.GET, requestOptions); + } + + public RedmineApiResponse GetPaged(string address, RequestOptions requestOptions = null) + { + return Get(address, requestOptions); + } + + public RedmineApiResponse Create(string address, string payload, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.POST, requestOptions, CreateContentFromPayload(payload)); + } + + public RedmineApiResponse Update(string address, string payload, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.PUT, requestOptions, CreateContentFromPayload(payload)); + } + + public RedmineApiResponse Patch(string address, string payload, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.PATCH, requestOptions, CreateContentFromPayload(payload)); + } + + public RedmineApiResponse Delete(string address, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.DELETE, requestOptions); + } + + public RedmineApiResponse Download(string address, RequestOptions requestOptions = null, + IProgress progress = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.DOWNLOAD, requestOptions, progress: progress); + } + + public RedmineApiResponse Upload(string address, byte[] data, RequestOptions requestOptions = null) + { + return HandleRequest(address, HttpConstants.HttpVerbs.POST, requestOptions, CreateContentFromBytes(data)); + } + + protected abstract RedmineApiResponse HandleRequest( + string address, + string verb, + RequestOptions requestOptions = null, + object content = null, + IProgress progress = null); + + protected abstract object CreateContentFromPayload(string payload); + + protected abstract object CreateContentFromBytes(byte[] data); + + protected static bool IsGetOrDownload(string method) + { + return method is HttpConstants.HttpVerbs.GET or HttpConstants.HttpVerbs.DOWNLOAD; + } + + protected static void ReportProgress(IProgressprogress, long total, long bytesRead) + { + if (progress == null || total <= 0) + { + return; + } + var percent = (int)(bytesRead * 100L / total); + progress.Report(percent); + } + + // protected void LogRequest(string verb, string address, RequestOptions requestOptions) + // { + // if (_options.LoggingOptions?.IncludeHttpDetails == true) + // { + // _options.Logger.Debug($"Request HTTP {verb} {address}"); + // + // if (requestOptions?.QueryString != null) + // { + // _options.Logger.Debug($"Query parameters: {requestOptions.QueryString.ToQueryString()}"); + // } + // } + // } + // + // protected void LogResponse(HttpStatusCode statusCode) + // { + // if (_options.LoggingOptions?.IncludeHttpDetails == true) + // { + // _options.Logger.Debug($"Response status: {statusCode}"); + // } + // } + +} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/RedmineWebClientOptions.cs b/src/redmine-net-api/Http/RedmineApiClientOptions.cs similarity index 56% rename from src/redmine-net-api/Net/WebClient/RedmineWebClientOptions.cs rename to src/redmine-net-api/Http/RedmineApiClientOptions.cs index 714df02d..1cf7f6cc 100644 --- a/src/redmine-net-api/Net/WebClient/RedmineWebClientOptions.cs +++ b/src/redmine-net-api/Http/RedmineApiClientOptions.cs @@ -1,31 +1,19 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - using System; using System.Collections.Generic; using System.Net; using System.Net.Cache; +#if NET || NET471_OR_GREATER +using System.Net.Http; +#endif using System.Net.Security; using System.Security.Cryptography.X509Certificates; -namespace Redmine.Net.Api.Net.WebClient; +namespace Redmine.Net.Api.Http; + /// /// /// -public sealed class RedmineWebClientOptions: IRedmineWebClientOptions +public abstract class RedmineApiClientOptions : IRedmineApiClientOptions { /// /// @@ -40,7 +28,12 @@ public sealed class RedmineWebClientOptions: IRedmineWebClientOptions /// /// /// - public DecompressionMethods? DecompressionFormat { get; set; } + public DecompressionMethods? DecompressionFormat { get; set; } = +#if NET + DecompressionMethods.All; +#else + DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.None; +#endif /// /// @@ -57,20 +50,12 @@ public sealed class RedmineWebClientOptions: IRedmineWebClientOptions /// public IWebProxy Proxy { get; set; } - /// - /// - /// - public bool? KeepAlive { get; set; } - /// /// /// public int? MaxAutomaticRedirections { get; set; } - /// - /// - /// - public long? MaxRequestContentBufferSize { get; set; } + /// /// @@ -102,36 +87,38 @@ public sealed class RedmineWebClientOptions: IRedmineWebClientOptions /// public string Scheme { get; set; } = "https"; + /// /// /// - public RemoteCertificateValidationCallback ServerCertificateValidationCallback { get; set; } + public TimeSpan? Timeout { get; set; } = TimeSpan.FromSeconds(30); /// /// /// - public TimeSpan? Timeout { get; set; } + public string UserAgent { get; set; } = "RedmineDotNetAPIClient"; /// /// /// - public bool? UnsafeAuthenticatedConnectionSharing { get; set; } + public bool? UseCookies { get; set; } - /// +#if NETFRAMEWORK + /// /// /// - public string UserAgent { get; set; } = "RedmineDotNetAPIClient"; + public bool CheckCertificateRevocationList { get; set; } - /// + /// /// /// - public bool? UseCookies { get; set; } + public long? MaxRequestContentBufferSize { get; set; } /// /// /// public bool? UseDefaultCredentials { get; set; } - +#endif /// /// /// @@ -143,53 +130,15 @@ public sealed class RedmineWebClientOptions: IRedmineWebClientOptions /// Only HTTP/1.0 and HTTP/1.1 version requests are currently supported. public Version ProtocolVersion { get; set; } + - #if NET40_OR_GREATER || NETCOREAPP - /// - /// - /// - public X509CertificateCollection ClientCertificates { get; set; } - #endif - - /// - /// - /// - public bool CheckCertificateRevocationList { get; set; } - - /// - /// - /// - public int? DefaultConnectionLimit { get; set; } - - /// - /// - /// - public int? DnsRefreshTimeout { get; set; } - - /// - /// - /// - public bool? EnableDnsRoundRobin { get; set; } - - /// - /// - /// - public int? MaxServicePoints { get; set; } - - /// - /// - /// - public int? MaxServicePointIdleTime { get; set; } - #if(NET46_OR_GREATER || NETCOREAPP) +#if NET40_OR_GREATER || NETCOREAPP /// /// /// - public bool? ReusePort { get; set; } - #endif + public X509CertificateCollection ClientCertificates { get; set; } +#endif - /// - /// - /// - public SecurityProtocolType? SecurityProtocolType { get; set; } + } \ No newline at end of file diff --git a/src/redmine-net-api/Net/RequestOptions.cs b/src/redmine-net-api/Http/RequestOptions.cs similarity index 64% rename from src/redmine-net-api/Net/RequestOptions.cs rename to src/redmine-net-api/Http/RequestOptions.cs index 1c31f7a6..1e06ae53 100644 --- a/src/redmine-net-api/Net/RequestOptions.cs +++ b/src/redmine-net-api/Http/RequestOptions.cs @@ -14,9 +14,11 @@ You may obtain a copy of the License at limitations under the License. */ +using System.Collections.Generic; using System.Collections.Specialized; +using Redmine.Net.Api.Extensions; -namespace Redmine.Net.Api.Net; +namespace Redmine.Net.Api.Http; /// /// @@ -43,6 +45,11 @@ public sealed class RequestOptions /// /// public string UserAgent { get; set; } + + /// + /// + /// + public Dictionary Headers { get; set; } /// /// @@ -56,7 +63,31 @@ public RequestOptions Clone() ImpersonateUser = ImpersonateUser, ContentType = ContentType, Accept = Accept, - UserAgent = UserAgent + UserAgent = UserAgent, + Headers = Headers != null ? new Dictionary(Headers) : null, + }; + } + + /// + /// + /// + /// + /// + public static RequestOptions Include(string include) + { + if (include.IsNullOrWhiteSpace()) + { + return null; + } + + var requestOptions = new RequestOptions + { + QueryString = new NameValueCollection + { + {RedmineKeys.INCLUDE, include} + } }; + + return requestOptions; } } \ No newline at end of file diff --git a/src/redmine-net-api/IRedmineManagerAsync.cs b/src/redmine-net-api/IRedmineManager.Async.cs similarity index 97% rename from src/redmine-net-api/IRedmineManagerAsync.cs rename to src/redmine-net-api/IRedmineManager.Async.cs index 4d2343d6..0cb733b5 100644 --- a/src/redmine-net-api/IRedmineManagerAsync.cs +++ b/src/redmine-net-api/IRedmineManager.Async.cs @@ -15,9 +15,12 @@ limitations under the License. */ #if !(NET20) +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Http; using Redmine.Net.Api.Net; using Redmine.Net.Api.Serialization; using Redmine.Net.Api.Types; @@ -147,9 +150,10 @@ Task DeleteAsync(string id, RequestOptions requestOptions = null, Cancellatio /// /// The address. /// + /// /// /// - Task DownloadFileAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task DownloadFileAsync(string address, RequestOptions requestOptions = null, IProgress progress = null, CancellationToken cancellationToken = default); } } #endif \ No newline at end of file diff --git a/src/redmine-net-api/IRedmineManager.Obsolete.cs b/src/redmine-net-api/IRedmineManager.Obsolete.cs deleted file mode 100644 index 028984a3..00000000 --- a/src/redmine-net-api/IRedmineManager.Obsolete.cs +++ /dev/null @@ -1,306 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Net; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Redmine.Net.Api.Serialization; - -namespace Redmine.Net.Api.Types -{ - /// - /// - /// - public partial interface IRedmineManager - { - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - string Host { get; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - string ApiKey { get; } - - /// - /// Maximum page-size when retrieving complete object lists - /// - /// By default, only 25 results can be retrieved per request. Maximum is 100. To change the maximum value set - /// in your Settings -> General, "Objects per page options".By adding (for instance) 9999 there would make you - /// able to get that many results per request. - /// - /// - /// - /// The size of the page. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - int PageSize { get; set; } - - /// - /// As of Redmine 2.2.0 you can impersonate user setting user login (e.g. jsmith). This only works when using the API - /// with an administrator account, this header will be ignored when using the API with a regular user account. - /// - /// - /// The impersonate user. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - string ImpersonateUser { get; set; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - MimeFormat MimeFormat { get; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - IWebProxy Proxy { get; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - SecurityProtocolType SecurityProtocolType { get; } - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'GetCurrentUser' extension instead")] - User GetCurrentUser(NameValueCollection parameters = null); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'AddUserToGroup' extension instead")] - void AddUserToGroup(int groupId, int userId); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'RemoveUserFromGroup' extension instead")] - void RemoveUserFromGroup(int groupId, int userId); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'AddWatcherToIssue' extension instead")] - void AddWatcherToIssue(int issueId, int userId); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'RemoveWatcherFromIssue' extension instead")] - void RemoveWatcherFromIssue(int issueId, int userId); - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'CreateWikiPage' extension instead")] - WikiPage CreateWikiPage(string projectId, string pageName, WikiPage wikiPage); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'UpdateWikiPage' extension instead")] - void UpdateWikiPage(string projectId, string pageName, WikiPage wikiPage); - - /// - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'GetWikiPage' extension instead")] - WikiPage GetWikiPage(string projectId, NameValueCollection parameters, string pageName, uint version = 0); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'GetAllWikiPages' extension instead")] - List GetAllWikiPages(string projectId); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'DeleteWikiPage' extension instead")] - void DeleteWikiPage(string projectId, string pageName); - - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'UpdateAttachment' extension instead")] - void UpdateAttachment(int issueId, Attachment attachment); - - /// - /// - /// - /// query strings. enable to specify multiple values separated by a space " ". - /// number of results in response. - /// skip this number of results in response - /// Optional filters. - /// - /// Returns the search results by the specified condition parameters. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Search' extension instead")] - PagedResults Search(string q, int limit , int offset = 0, SearchFilterBuilder searchFilter = null); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'GetPaginated' method instead")] - PagedResults GetPaginatedObjects(NameValueCollection parameters) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Count' method instead")] - int Count(params string[] include) where T : class, new(); - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Get' method instead")] - T GetObject(string id, NameValueCollection parameters) where T : class, new(); - - /// - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Get' method instead")] - List GetObjects(int limit, int offset, params string[] include) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Get' method instead")] - List GetObjects(params string[] include) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Get' method instead")] - List GetObjects(NameValueCollection parameters) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Create' method instead")] - T CreateObject(T entity) where T : class, new(); - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Create' method instead")] - T CreateObject(T entity, string ownerId) where T : class, new(); - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Update' method instead")] - void UpdateObject(string id, T entity, string projectId = null) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use 'Delete' method instead")] - void DeleteObject(string id, NameValueCollection parameters = null) where T : class, new(); - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - RedmineWebClient CreateWebClient(NameValueCollection parameters, bool uploadFile = false); - /// - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - bool RemoteCertValidate(object sender, X509Certificate cert, X509Chain chain, SslPolicyErrors sslPolicyErrors); - } -} \ No newline at end of file diff --git a/src/redmine-net-api/IRedmineManager.cs b/src/redmine-net-api/IRedmineManager.cs index 5e4f5437..e7f487dc 100644 --- a/src/redmine-net-api/IRedmineManager.cs +++ b/src/redmine-net-api/IRedmineManager.cs @@ -14,12 +14,14 @@ You may obtain a copy of the License at limitations under the License. */ +using System; using System.Collections.Generic; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; -namespace Redmine.Net.Api.Types; +namespace Redmine.Net.Api; /// /// @@ -96,21 +98,22 @@ void Delete(string id, RequestOptions requestOptions = null) /// /// Support for adding attachments through the REST API is added in Redmine 1.4.0. - /// Upload a file to server. + /// Upload a file to the server. /// /// The content of the file that will be uploaded on server. /// /// - /// Returns the token for uploaded file. + /// Returns the token for the uploaded file. /// /// Upload UploadFile(byte[] data, string fileName = null); - + /// /// Downloads a file from the specified address. /// /// The address. + /// /// The content of the downloaded file as a byte array. /// - byte[] DownloadFile(string address); + byte[] DownloadFile(string address, IProgress progress = null); } \ No newline at end of file diff --git a/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs b/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs index 86f45107..e7daa84e 100644 --- a/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs +++ b/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +#nullable enable + namespace Redmine.Net.Api.Internals; internal static class ArgumentNullThrowHelper diff --git a/src/redmine-net-api/Internals/HostHelper.cs b/src/redmine-net-api/Internals/HostHelper.cs new file mode 100644 index 00000000..e5a0fe0e --- /dev/null +++ b/src/redmine-net-api/Internals/HostHelper.cs @@ -0,0 +1,172 @@ +using System; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; + +namespace Redmine.Net.Api.Internals; + +internal static class HostHelper +{ + private static readonly char[] DotCharArray = ['.']; + + internal static void EnsureDomainNameIsValid(string domainName) + { + if (domainName.IsNullOrWhiteSpace()) + { + throw new RedmineException("Domain name cannot be null or empty."); + } + + if (domainName.Length > 255) + { + throw new RedmineException("Domain name cannot be longer than 255 characters."); + } + + var labels = domainName.Split(DotCharArray); + if (labels.Length == 1) + { + throw new RedmineException("Domain name is not valid."); + } + + foreach (var label in labels) + { + if (label.IsNullOrWhiteSpace() || label.Length > 63) + { + throw new RedmineException("Domain name must be between 1 and 63 characters."); + } + + if (!char.IsLetterOrDigit(label[0]) || !char.IsLetterOrDigit(label[label.Length - 1])) + { + throw new RedmineException("Domain name label starts or ends with a hyphen or invalid character."); + } + + for (var index = 0; index < label.Length; index++) + { + var ch = label[index]; + + if (!char.IsLetterOrDigit(ch) && ch != '-') + { + throw new RedmineException("Domain name contains an invalid character."); + } + + if (ch == '-' && index + 1 < label.Length && label[index + 1] == '-') + { + throw new RedmineException("Domain name contains consecutive hyphens."); + } + } + } + } + + internal static Uri CreateRedmineUri(string host, string scheme = null) + { + if (host.IsNullOrWhiteSpace()) + { + throw new RedmineException("The host is null or empty."); + } + + if (!Uri.TryCreate(host, UriKind.Absolute, out var uri)) + { + host = host.TrimEnd('/', '\\'); + EnsureDomainNameIsValid(host); + + if (!host.StartsWith(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + !host.StartsWith(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + host = $"{scheme ?? Uri.UriSchemeHttps}://{host}"; + + if (!Uri.TryCreate(host, UriKind.Absolute, out uri)) + { + throw new RedmineException("The host is not valid."); + } + } + } + + if (!uri.IsWellFormedOriginalString()) + { + throw new RedmineException("The host is not well-formed."); + } + + scheme ??= Uri.UriSchemeHttps; + var hasScheme = false; + if (!uri.Scheme.IsNullOrWhiteSpace()) + { + if (uri.Host.IsNullOrWhiteSpace() && uri.IsAbsoluteUri && !uri.IsFile) + { + if (uri.Scheme.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + int port = 0; + var portAsString = uri.AbsolutePath.RemoveTrailingSlash(); + if (!portAsString.IsNullOrWhiteSpace()) + { + int.TryParse(portAsString, out port); + } + + var ub = new UriBuilder(scheme, "localhost", port); + return ub.Uri; + } + } + else + { + if (!IsSchemaHttpOrHttps(uri.Scheme)) + { + throw new RedmineException("Invalid host scheme. Only HTTP and HTTPS are supported."); + } + + hasScheme = true; + } + } + else + { + if (!IsSchemaHttpOrHttps(scheme)) + { + throw new RedmineException("Invalid host scheme. Only HTTP and HTTPS are supported."); + } + } + + var uriBuilder = new UriBuilder(); + + if (uri.HostNameType == UriHostNameType.IPv6) + { + uriBuilder.Scheme = (hasScheme ? uri.Scheme : scheme ?? Uri.UriSchemeHttps); + uriBuilder.Host = uri.Host; + } + else + { + if (uri.Authority.IsNullOrWhiteSpace()) + { + if (uri.Port == -1) + { + if (int.TryParse(uri.LocalPath, out var port)) + { + uriBuilder.Port = port; + } + } + + uriBuilder.Scheme = scheme ?? Uri.UriSchemeHttps; + uriBuilder.Host = uri.Scheme; + } + else + { + uriBuilder.Scheme = uri.Scheme; + uriBuilder.Port = int.TryParse(uri.LocalPath, out var port) ? port : uri.Port; + uriBuilder.Host = uri.Host; + if (!uri.LocalPath.IsNullOrWhiteSpace() && !uri.LocalPath.Contains(".")) + { + uriBuilder.Path = uri.LocalPath; + } + } + } + + try + { + return uriBuilder.Uri; + } + catch (Exception ex) + { + throw new RedmineException($"Failed to create Redmine URI: {ex.Message}", ex); + } + } + + private static bool IsSchemaHttpOrHttps(string scheme) + { + return scheme == Uri.UriSchemeHttp || scheme == Uri.UriSchemeHttps; + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/ColorConsoleLogger.cs b/src/redmine-net-api/Logging/ColorConsoleLogger.cs deleted file mode 100644 index e7959dc9..00000000 --- a/src/redmine-net-api/Logging/ColorConsoleLogger.cs +++ /dev/null @@ -1,110 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - /// - public sealed class ColorConsoleLogger : ILogger - { - private static readonly object locker = new object(); - private readonly ConsoleColor? defaultConsoleColor = null; - - /// - /// - /// - public void Log(LogEntry entry) - { - lock (locker) - { - var colors = GetLogLevelConsoleColors(entry.Severity); - switch (entry.Severity) - { - case LoggingEventType.Debug: - Console.WriteLine(entry.Message, colors.Background, colors.Foreground); - break; - case LoggingEventType.Information: - Console.WriteLine(entry.Message, colors.Background, colors.Foreground); - break; - case LoggingEventType.Warning: - Console.WriteLine(entry.Message, colors.Background, colors.Foreground); - break; - case LoggingEventType.Error: - Console.WriteLine(entry.Message, colors.Background, colors.Foreground); - break; - case LoggingEventType.Fatal: - Console.WriteLine(entry.Message, colors.Background, colors.Foreground); - break; - } - } - } - - /// - /// Gets the log level console colors. - /// - /// The log level. - /// - private ConsoleColors GetLogLevelConsoleColors(LoggingEventType logLevel) - { - // do not change user's background color except for Critical - switch (logLevel) - { - case LoggingEventType.Fatal: - return new ConsoleColors(ConsoleColor.White, ConsoleColor.Red); - case LoggingEventType.Error: - return new ConsoleColors(ConsoleColor.Red, defaultConsoleColor); - case LoggingEventType.Warning: - return new ConsoleColors(ConsoleColor.DarkYellow, defaultConsoleColor); - case LoggingEventType.Information: - return new ConsoleColors(ConsoleColor.DarkGreen, defaultConsoleColor); - default: - return new ConsoleColors(ConsoleColor.Gray, defaultConsoleColor); - } - } - - /// - /// - /// - private struct ConsoleColors - { - public ConsoleColors(ConsoleColor? foreground, ConsoleColor? background): this() - { - Foreground = foreground; - Background = background; - } - - /// - /// Gets or sets the foreground. - /// - /// - /// The foreground. - /// - public ConsoleColor? Foreground { get; private set; } - - /// - /// Gets or sets the background. - /// - /// - /// The background. - /// - public ConsoleColor? Background { get; private set; } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/ConsoleLogger.cs b/src/redmine-net-api/Logging/ConsoleLogger.cs deleted file mode 100644 index 015b465a..00000000 --- a/src/redmine-net-api/Logging/ConsoleLogger.cs +++ /dev/null @@ -1,56 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - /// - public sealed class ConsoleLogger : ILogger - { - private static readonly object locker = new object(); - /// - /// - /// - public void Log(LogEntry entry) - { - lock (locker) - { - switch (entry.Severity) - { - case LoggingEventType.Debug: - Console.WriteLine(entry.Message); - break; - case LoggingEventType.Information: - Console.WriteLine(entry.Message); - break; - case LoggingEventType.Warning: - Console.WriteLine(entry.Message); - break; - case LoggingEventType.Error: - Console.WriteLine(entry.Message); - break; - case LoggingEventType.Fatal: - Console.WriteLine(entry.Message); - break; - } - } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/ILogger.cs b/src/redmine-net-api/Logging/ILogger.cs deleted file mode 100755 index 5d483046..00000000 --- a/src/redmine-net-api/Logging/ILogger.cs +++ /dev/null @@ -1,30 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public interface ILogger - { - /// - /// Logs the specified entry. - /// - /// The entry. - void Log(LogEntry entry); - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/IRedmineLogger.cs b/src/redmine-net-api/Logging/IRedmineLogger.cs new file mode 100644 index 00000000..47b4c980 --- /dev/null +++ b/src/redmine-net-api/Logging/IRedmineLogger.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Redmine.Net.Api.Logging; + +/// +/// Provides abstraction for logging operations +/// +public interface IRedmineLogger +{ + /// + /// Checks if the specified log level is enabled + /// + bool IsEnabled(LogLevel level); + + /// + /// Logs a message with the specified level + /// + void Log(LogLevel level, string message, Exception exception = null); + + /// + /// Creates a scoped logger with additional context + /// + IRedmineLogger CreateScope(string scopeName, IDictionary scopeProperties = null); +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/LogEntry.cs b/src/redmine-net-api/Logging/LogEntry.cs deleted file mode 100644 index c91218cc..00000000 --- a/src/redmine-net-api/Logging/LogEntry.cs +++ /dev/null @@ -1,61 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public sealed class LogEntry - { - /// - /// Initializes a new instance of the class. - /// - /// The severity. - /// The message. - /// The exception. - public LogEntry(LoggingEventType severity, string message, Exception exception = null) - { - Severity = severity; - Message = message; - Exception = exception; - } - - /// - /// Gets the severity. - /// - /// - /// The severity. - /// - public LoggingEventType Severity { get; private set; } - /// - /// Gets the message. - /// - /// - /// The message. - /// - public string Message { get; private set; } - /// - /// Gets the exception. - /// - /// - /// The exception. - /// - public Exception Exception { get; private set; } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/LogLevel.cs b/src/redmine-net-api/Logging/LogLevel.cs new file mode 100644 index 00000000..a58e1500 --- /dev/null +++ b/src/redmine-net-api/Logging/LogLevel.cs @@ -0,0 +1,32 @@ +namespace Redmine.Net.Api.Logging; + +/// +/// Defines logging severity levels +/// +public enum LogLevel +{ + /// + /// + /// + Trace, + /// + /// + /// + Debug, + /// + /// + /// + Information, + /// + /// + /// + Warning, + /// + /// + /// + Error, + /// + /// + /// + Critical +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/Logger.cs b/src/redmine-net-api/Logging/Logger.cs deleted file mode 100755 index 895f3a8e..00000000 --- a/src/redmine-net-api/Logging/Logger.cs +++ /dev/null @@ -1,51 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public static class Logger - { - private static readonly object locker = new object(); - private static ILogger logger; - - /// - /// Gets the current ILogger. - /// - /// - /// The current. - /// - public static ILogger Current - { - get { return logger ?? (logger = new ConsoleLogger()); } - private set { logger = value; } - } - - /// - /// Uses the logger. - /// - /// The logger. - public static void UseLogger(ILogger logger) - { - lock (locker) - { - Current = logger; - } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/LoggerExtensions.cs b/src/redmine-net-api/Logging/LoggerExtensions.cs deleted file mode 100755 index 5a55d1e8..00000000 --- a/src/redmine-net-api/Logging/LoggerExtensions.cs +++ /dev/null @@ -1,225 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Globalization; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public static class LoggerExtensions - { - /// - /// Debugs the specified message. - /// - /// The logger. - /// The message. - public static void Debug(this ILogger logger, string message) - { - logger.Log(new LogEntry(LoggingEventType.Debug, message)); - } - - /// - /// Debugs the specified message. - /// - /// The logger. - /// The message. - /// The exception. - public static void Debug(this ILogger logger, string message, Exception exception) - { - logger.Log(new LogEntry(LoggingEventType.Debug, message, exception)); - } - - /// - /// Debugs the specified format provider. - /// - /// The logger. - /// The format provider. - /// The format. - /// The arguments. - public static void Debug(this ILogger logger, IFormatProvider formatProvider, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Debug, string.Format(formatProvider, format, args))); - } - - /// - /// Debugs the specified format. - /// - /// The logger. - /// The format. - /// The arguments. - public static void Debug(this ILogger logger, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Debug, string.Format(CultureInfo.CurrentCulture, format, args))); - } - - /// - /// Informations the specified message. - /// - /// The logger. - /// The message. - public static void Information(this ILogger logger, string message) - { - logger.Log(new LogEntry(LoggingEventType.Information, message)); - } - - /// - /// Informations the specified format provider. - /// - /// The logger. - /// The format provider. - /// The format. - /// The arguments. - public static void Information(this ILogger logger, IFormatProvider formatProvider, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Information, string.Format(formatProvider, format, args))); - } - - /// - /// Informations the specified format. - /// - /// The logger. - /// The format. - /// The arguments. - public static void Information(this ILogger logger, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Information, string.Format(CultureInfo.CurrentCulture, format, args))); - } - - /// - /// Warnings the specified message. - /// - /// The logger. - /// The message. - public static void Warning(this ILogger logger, string message) - { - logger.Log(new LogEntry(LoggingEventType.Warning, message)); - } - - /// - /// Warnings the specified format provider. - /// - /// The logger. - /// The format provider. - /// The format. - /// The arguments. - public static void Warning(this ILogger logger, IFormatProvider formatProvider, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Warning, string.Format(formatProvider, format, args))); - } - - /// - /// Warnings the specified format. - /// - /// The logger. - /// The format. - /// The arguments. - public static void Warning(this ILogger logger, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Warning, string.Format(CultureInfo.CurrentCulture, format, args))); - } - - /// - /// Errors the specified exception. - /// - /// The logger. - /// The exception. - public static void Error(this ILogger logger, Exception exception) - { - logger.Log(new LogEntry(LoggingEventType.Error, exception.Message, exception)); - } - - /// - /// Errors the specified message. - /// - /// The logger. - /// The message. - /// The exception. - public static void Error(this ILogger logger, string message, Exception exception) - { - logger.Log(new LogEntry(LoggingEventType.Error, message, exception)); - } - - /// - /// Errors the specified format provider. - /// - /// The logger. - /// The format provider. - /// The format. - /// The arguments. - public static void Error(this ILogger logger, IFormatProvider formatProvider, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Error, string.Format(formatProvider, format, args))); - } - - /// - /// Errors the specified format. - /// - /// The logger. - /// The format. - /// The arguments. - public static void Error(this ILogger logger, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Error, string.Format(CultureInfo.CurrentCulture, format, args))); - } - - /// - /// Fatals the specified exception. - /// - /// The logger. - /// The exception. - public static void Fatal(this ILogger logger, Exception exception) - { - logger.Log(new LogEntry(LoggingEventType.Fatal, exception.Message, exception)); - } - - /// - /// Fatals the specified message. - /// - /// The logger. - /// The message. - /// The exception. - public static void Fatal(this ILogger logger, string message, Exception exception) - { - logger.Log(new LogEntry(LoggingEventType.Fatal, message, exception)); - } - - /// - /// Fatals the specified format provider. - /// - /// The logger. - /// The format provider. - /// The format. - /// The arguments. - public static void Fatal(this ILogger logger, IFormatProvider formatProvider, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Fatal, string.Format(formatProvider, format, args))); - } - - /// - /// Fatals the specified format. - /// - /// The logger. - /// The format. - /// The arguments. - public static void Fatal(this ILogger logger, string format, params object[] args) - { - logger.Log(new LogEntry(LoggingEventType.Fatal, string.Format(CultureInfo.CurrentCulture, format, args))); - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/LoggingEventType.cs b/src/redmine-net-api/Logging/LoggingEventType.cs deleted file mode 100755 index e539f868..00000000 --- a/src/redmine-net-api/Logging/LoggingEventType.cs +++ /dev/null @@ -1,45 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public enum LoggingEventType - { - /// - /// The debug - /// - Debug, - /// - /// The information - /// - Information, - /// - /// The warning - /// - Warning, - /// - /// The error - /// - Error, - /// - /// The fatal - /// - Fatal - }; -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/MicrosoftLoggerRedmineAdapter.cs b/src/redmine-net-api/Logging/MicrosoftLoggerRedmineAdapter.cs new file mode 100644 index 00000000..1ac86ef6 --- /dev/null +++ b/src/redmine-net-api/Logging/MicrosoftLoggerRedmineAdapter.cs @@ -0,0 +1,92 @@ +#if NET462_OR_GREATER || NETCOREAPP +using System; +using System.Collections.Generic; + +namespace Redmine.Net.Api.Logging; + +/// +/// Adapter that converts Microsoft.Extensions.Logging.ILogger to IRedmineLogger +/// +public class MicrosoftLoggerRedmineAdapter : IRedmineLogger +{ + private readonly Microsoft.Extensions.Logging.ILogger _microsoftLogger; + + /// + /// Creates a new adapter for Microsoft.Extensions.Logging.ILogger + /// + /// The Microsoft logger to adapt + /// Thrown if microsoftLogger is null + public MicrosoftLoggerRedmineAdapter(Microsoft.Extensions.Logging.ILogger microsoftLogger) + { + _microsoftLogger = microsoftLogger ?? throw new ArgumentNullException(nameof(microsoftLogger)); + } + + /// + /// Checks if logging is enabled for the specified level + /// + public bool IsEnabled(LogLevel level) + { + return _microsoftLogger.IsEnabled(ToMicrosoftLogLevel(level)); + } + + /// + /// Logs a message with the specified level + /// + public void Log(LogLevel level, string message, Exception exception = null) + { + _microsoftLogger.Log( + ToMicrosoftLogLevel(level), + 0, // eventId + message, + exception, + (s, e) => s); + } + + /// + /// Creates a scoped logger with additional context + /// + public IRedmineLogger CreateScope(string scopeName, IDictionary scopeProperties = null) + { + var scopeData = new Dictionary + { + ["ScopeName"] = scopeName + }; + + // Add additional properties if provided + if (scopeProperties != null) + { + foreach (var prop in scopeProperties) + { + scopeData[prop.Key] = prop.Value; + } + } + + // Create a single scope with all properties + var disposableScope = _microsoftLogger.BeginScope(scopeData); + + // Return a new adapter that will close the scope when disposed + return new ScopedMicrosoftLoggerAdapter(_microsoftLogger, disposableScope); + } + + private class ScopedMicrosoftLoggerAdapter(Microsoft.Extensions.Logging.ILogger logger, IDisposable scope) + : MicrosoftLoggerRedmineAdapter(logger), IDisposable + { + public void Dispose() + { + scope?.Dispose(); + } + } + + + private static Microsoft.Extensions.Logging.LogLevel ToMicrosoftLogLevel(LogLevel level) => level switch + { + LogLevel.Trace => Microsoft.Extensions.Logging.LogLevel.Trace, + LogLevel.Debug => Microsoft.Extensions.Logging.LogLevel.Debug, + LogLevel.Information => Microsoft.Extensions.Logging.LogLevel.Information, + LogLevel.Warning => Microsoft.Extensions.Logging.LogLevel.Warning, + LogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error, + LogLevel.Critical => Microsoft.Extensions.Logging.LogLevel.Critical, + _ => Microsoft.Extensions.Logging.LogLevel.Information + }; +} +#endif diff --git a/src/redmine-net-api/Logging/RedmineConsoleLogger.cs b/src/redmine-net-api/Logging/RedmineConsoleLogger.cs new file mode 100644 index 00000000..23b03cea --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineConsoleLogger.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace Redmine.Net.Api.Logging; + +/// +/// +/// +/// +/// +/// +/// +/// +public class RedmineConsoleLogger(string categoryName = "Redmine", LogLevel minLevel = LogLevel.Information) : IRedmineLogger +{ + /// + /// + /// + /// + /// + public bool IsEnabled(LogLevel level) => level >= minLevel; + + /// + /// + /// + /// + /// + /// + public void Log(LogLevel level, string message, Exception exception = null) + { + if (!IsEnabled(level)) + { + return; + } + + // var originalColor = Console.ForegroundColor; + // + // Console.ForegroundColor = level switch + // { + // LogLevel.Trace => ConsoleColor.Gray, + // LogLevel.Debug => ConsoleColor.Gray, + // LogLevel.Information => ConsoleColor.White, + // LogLevel.Warning => ConsoleColor.Yellow, + // LogLevel.Error => ConsoleColor.Red, + // LogLevel.Critical => ConsoleColor.Red, + // _ => ConsoleColor.White + // }; + + Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] [{categoryName}] {message}"); + + if (exception != null) + { + Console.WriteLine($"Exception: {exception}"); + } + + // Console.ForegroundColor = originalColor; + } + + /// + /// + /// + /// + /// + /// + public IRedmineLogger CreateScope(string scopeName, IDictionary scopeProperties = null) + { + return new RedmineConsoleLogger($"{categoryName}.{scopeName}", minLevel); + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineConsoleTraceListener.cs b/src/redmine-net-api/Logging/RedmineConsoleTraceListener.cs deleted file mode 100644 index 531f4371..00000000 --- a/src/redmine-net-api/Logging/RedmineConsoleTraceListener.cs +++ /dev/null @@ -1,86 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Diagnostics; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public sealed class RedmineConsoleTraceListener : TraceListener - { - #region implemented abstract members of TraceListener - - /// - /// When overridden in a derived class, writes the specified message to the listener you create in the derived class. - /// - /// A message to write. - public override void Write (string message) - { - Console.Write(message); - } - - /// - /// When overridden in a derived class, writes a message to the listener you create in the derived class, followed by a line terminator. - /// - /// A message to write. - public override void WriteLine (string message) - { - Console.WriteLine(message); - } - - #endregion - - /// - /// Writes trace information, a message, and event information to the listener specific output. - /// - /// A object that contains the current process ID, thread ID, and stack trace information. - /// A name used to identify the output, typically the name of the application that generated the trace event. - /// One of the values specifying the type of event that has caused the trace. - /// A numeric identifier for the event. - /// A message to write. - /// - /// - /// - /// - public override void TraceEvent(TraceEventCache eventCache, string source, TraceEventType eventType, int id, - string message) - { - WriteLine(message); - } - - /// - /// Writes trace information, a formatted array of objects and event information to the listener specific output. - /// - /// A object that contains the current process ID, thread ID, and stack trace information. - /// A name used to identify the output, typically the name of the application that generated the trace event. - /// One of the values specifying the type of event that has caused the trace. - /// A numeric identifier for the event. - /// A format string that contains zero or more format items, which correspond to objects in the array. - /// An object array containing zero or more objects to format. - /// - /// - /// - /// - public override void TraceEvent(TraceEventCache eventCache, string source, TraceEventType eventType, int id, - string format, params object[] args) - { - WriteLine(string.Format(format, args)); - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineLoggerExtensions.cs b/src/redmine-net-api/Logging/RedmineLoggerExtensions.cs new file mode 100644 index 00000000..39012412 --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineLoggerExtensions.cs @@ -0,0 +1,108 @@ +using System; +using System.Diagnostics; +#if !(NET20 || NET40) +using System.Threading.Tasks; +#endif +namespace Redmine.Net.Api.Logging; + +/// +/// +/// +public static class RedmineLoggerExtensions +{ + /// + /// + /// + /// + /// + /// + public static void Trace(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Trace, message, exception); + + /// + /// + /// + /// + /// + /// + public static void Debug(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Debug, message, exception); + + /// + /// + /// + /// + /// + /// + public static void Info(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Information, message, exception); + + /// + /// + /// + /// + /// + /// + public static void Warn(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Warning, message, exception); + + /// + /// + /// + /// + /// + /// + public static void Error(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Error, message, exception); + + /// + /// + /// + /// + /// + /// + public static void Critical(this IRedmineLogger logger, string message, Exception exception = null) + => logger.Log(LogLevel.Critical, message, exception); + +#if !(NET20 || NET40) + /// + /// Creates and logs timing information for an operation + /// + public static async Task TimeOperationAsync(this IRedmineLogger logger, string operationName, Func> operation) + { + if (!logger.IsEnabled(LogLevel.Debug)) + return await operation().ConfigureAwait(false); + + var sw = Stopwatch.StartNew(); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + sw.Stop(); + logger.Debug($"Operation '{operationName}' completed in {sw.ElapsedMilliseconds}ms"); + } + } + #endif + + /// + /// Creates and logs timing information for an operation + /// + public static T TimeOperationAsync(this IRedmineLogger logger, string operationName, Func operation) + { + if (!logger.IsEnabled(LogLevel.Debug)) + return operation(); + + var sw = Stopwatch.StartNew(); + try + { + return operation(); + } + finally + { + sw.Stop(); + logger.Debug($"Operation '{operationName}' completed in {sw.ElapsedMilliseconds}ms"); + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineLoggerFactory.cs b/src/redmine-net-api/Logging/RedmineLoggerFactory.cs new file mode 100644 index 00000000..9b2dddff --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineLoggerFactory.cs @@ -0,0 +1,75 @@ +#if NET462_OR_GREATER || NETCOREAPP +namespace Redmine.Net.Api.Logging; + +/// +/// +/// +public static class RedmineLoggerFactory +{ + /// + /// + /// + /// + /// + /// + public static Microsoft.Extensions.Logging.ILogger CreateMicrosoftLoggerAdapter(IRedmineLogger redmineLogger, + string categoryName = "Redmine") + { + if (redmineLogger == null || redmineLogger == RedmineNullLogger.Instance) + { + return Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + return new RedmineLoggerMicrosoftAdapter(redmineLogger, categoryName); + } + + /// + /// Creates an adapter that exposes a Microsoft.Extensions.Logging.ILogger as IRedmineLogger + /// + /// The Microsoft logger to adapt + /// A Redmine logger implementation + public static IRedmineLogger CreateMicrosoftLogger(Microsoft.Extensions.Logging.ILogger microsoftLogger) + { + return microsoftLogger != null + ? new MicrosoftLoggerRedmineAdapter(microsoftLogger) + : RedmineNullLogger.Instance; + } + + /// + /// Creates a logger that writes to the console + /// + public static IRedmineLogger CreateConsoleLogger(LogLevel minLevel = LogLevel.Information) + { + return new RedmineConsoleLogger(minLevel: minLevel); + } + + // /// + // /// Creates an adapter for Serilog + // /// + // public static IRedmineLogger CreateSerilogAdapter(Serilog.ILogger logger) + // { + // if (logger == null) return NullRedmineLogger.Instance; + // return new SerilogAdapter(logger); + // } + // + // /// + // /// Creates an adapter for NLog + // /// + // public static IRedmineLogger CreateNLogAdapter(NLog.ILogger logger) + // { + // if (logger == null) return NullRedmineLogger.Instance; + // return new NLogAdapter(logger); + // } + // + // /// + // /// Creates an adapter for log4net + // /// + // public static IRedmineLogger CreateLog4NetAdapter(log4net.ILog logger) + // { + // if (logger == null) return NullRedmineLogger.Instance; + // return new Log4NetAdapter(logger); + // } +} + + +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineLoggerMicrosoftAdapter.cs b/src/redmine-net-api/Logging/RedmineLoggerMicrosoftAdapter.cs new file mode 100644 index 00000000..56d152f9 --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineLoggerMicrosoftAdapter.cs @@ -0,0 +1,97 @@ +#if NET462_OR_GREATER || NETCOREAPP +using System; +using System.Collections.Generic; + +namespace Redmine.Net.Api.Logging; + +/// +/// +/// +public class RedmineLoggerMicrosoftAdapter : Microsoft.Extensions.Logging.ILogger +{ + private readonly IRedmineLogger _redmineLogger; + private readonly string _categoryName; + + /// + /// + /// + /// + /// + /// + public RedmineLoggerMicrosoftAdapter(IRedmineLogger redmineLogger, string categoryName = "Redmine.Net.Api") + { + _redmineLogger = redmineLogger ?? throw new ArgumentNullException(nameof(redmineLogger)); + _categoryName = categoryName; + } + + /// + /// + /// + /// + /// + /// + public IDisposable BeginScope(TState state) + { + if (state is IDictionary dict) + { + _redmineLogger.CreateScope("Scope", dict); + } + else + { + var scopeName = state?.ToString() ?? "Scope"; + _redmineLogger.CreateScope(scopeName); + } + + return new NoOpDisposable(); + } + + /// + /// + /// + /// + /// + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) + { + return _redmineLogger.IsEnabled(ToRedmineLogLevel(logLevel)); + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, + Microsoft.Extensions.Logging.EventId eventId, + TState state, + Exception exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + _redmineLogger.Log(ToRedmineLogLevel(logLevel), message, exception); + } + + private static LogLevel ToRedmineLogLevel(Microsoft.Extensions.Logging.LogLevel level) => level switch + { + Microsoft.Extensions.Logging.LogLevel.Trace => LogLevel.Trace, + Microsoft.Extensions.Logging.LogLevel.Debug => LogLevel.Debug, + Microsoft.Extensions.Logging.LogLevel.Information => LogLevel.Information, + Microsoft.Extensions.Logging.LogLevel.Warning => LogLevel.Warning, + Microsoft.Extensions.Logging.LogLevel.Error => LogLevel.Error, + Microsoft.Extensions.Logging.LogLevel.Critical => LogLevel.Critical, + _ => LogLevel.Information + }; + + private class NoOpDisposable : IDisposable + { + public void Dispose() { } + } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineLoggingOptions.cs b/src/redmine-net-api/Logging/RedmineLoggingOptions.cs new file mode 100644 index 00000000..d7b510d4 --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineLoggingOptions.cs @@ -0,0 +1,22 @@ +namespace Redmine.Net.Api.Logging; + +/// +/// Options for configuring Redmine logging +/// +public sealed class RedmineLoggingOptions +{ + /// + /// Gets or sets the minimum log level. The default value is LogLevel.Information + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Information; + + /// + /// Gets or sets whether to include HTTP request/response details in logs + /// + public bool IncludeHttpDetails { get; set; } + + /// + /// Gets or sets whether performance metrics should be logged + /// + public bool LogPerformanceMetrics { get; set; } +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/RedmineNullLogger.cs b/src/redmine-net-api/Logging/RedmineNullLogger.cs new file mode 100644 index 00000000..0d47ab1d --- /dev/null +++ b/src/redmine-net-api/Logging/RedmineNullLogger.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Redmine.Net.Api.Logging; + +/// +/// +/// +public class RedmineNullLogger : IRedmineLogger +{ + /// + /// + /// + public static readonly RedmineNullLogger Instance = new RedmineNullLogger(); + + private RedmineNullLogger() { } + + /// + /// + /// + /// + /// + public bool IsEnabled(LogLevel level) => false; + + /// + /// + /// + /// + /// + /// + public void Log(LogLevel level, string message, Exception exception = null) { } + + /// + /// + /// + /// + /// + /// + public IRedmineLogger CreateScope(string scopeName, IDictionary scopeProperties = null) => this; +} \ No newline at end of file diff --git a/src/redmine-net-api/Logging/TraceLogger.cs b/src/redmine-net-api/Logging/TraceLogger.cs deleted file mode 100644 index 324252dc..00000000 --- a/src/redmine-net-api/Logging/TraceLogger.cs +++ /dev/null @@ -1,56 +0,0 @@ -/* - Copyright 2011 - 2019 Adrian Popescu. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Diagnostics; - -namespace Redmine.Net.Api.Logging -{ - /// - /// - /// - public sealed class TraceLogger : ILogger - { - /// - /// Logs the specified entry. - /// - /// The entry. - /// - public void Log(LogEntry entry) - { - switch (entry.Severity) - { - case LoggingEventType.Debug: - Trace.WriteLine(entry.Message, "Debug"); - break; - case LoggingEventType.Information: - Trace.TraceInformation(entry.Message); - break; - case LoggingEventType.Warning: - Trace.TraceWarning(entry.Message); - break; - case LoggingEventType.Error: - Trace.TraceError(entry.Message); - break; - case LoggingEventType.Fatal: - Trace.WriteLine(entry.Message, "Fatal"); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/ApiRequestMessageContent.cs b/src/redmine-net-api/Net/ApiRequestMessageContent.cs deleted file mode 100644 index 94c5f5e9..00000000 --- a/src/redmine-net-api/Net/ApiRequestMessageContent.cs +++ /dev/null @@ -1,24 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Net; - -internal abstract class ApiRequestMessageContent -{ - public string ContentType { get; internal set; } - - public byte[] Body { get; internal set; } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/ApiResponseMessageExtensions.cs b/src/redmine-net-api/Net/ApiResponseMessageExtensions.cs deleted file mode 100644 index f039a451..00000000 --- a/src/redmine-net-api/Net/ApiResponseMessageExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System.Collections.Generic; -using System.Text; -using Redmine.Net.Api.Serialization; - -namespace Redmine.Net.Api.Net; - -internal static class ApiResponseMessageExtensions -{ - internal static T DeserializeTo(this ApiResponseMessage responseMessage, IRedmineSerializer redmineSerializer) where T : new() - { - if (responseMessage?.Content == null) - { - return default; - } - - var responseAsString = Encoding.UTF8.GetString(responseMessage.Content); - - return redmineSerializer.Deserialize(responseAsString); - } - - internal static PagedResults DeserializeToPagedResults(this ApiResponseMessage responseMessage, IRedmineSerializer redmineSerializer) where T : class, new() - { - if (responseMessage?.Content == null) - { - return default; - } - - var responseAsString = Encoding.UTF8.GetString(responseMessage.Content); - - return redmineSerializer.DeserializeToPagedResults(responseAsString); - } - - internal static List DeserializeToList(this ApiResponseMessage responseMessage, IRedmineSerializer redmineSerializer) where T : class, new() - { - if (responseMessage?.Content == null) - { - return default; - } - - var responseAsString = Encoding.UTF8.GetString(responseMessage.Content); - - return redmineSerializer.Deserialize>(responseAsString); - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/HttpVerbs.cs b/src/redmine-net-api/Net/HttpVerbs.cs deleted file mode 100644 index e7851896..00000000 --- a/src/redmine-net-api/Net/HttpVerbs.cs +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2011 - 2025 Adrian Popescu - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -namespace Redmine.Net.Api -{ - - /// - /// - /// - public static class HttpVerbs - { - /// - /// Represents an HTTP GET protocol method that is used to get an entity identified by a URI. - /// - public const string GET = "GET"; - /// - /// Represents an HTTP PUT protocol method that is used to replace an entity identified by a URI. - /// - public const string PUT = "PUT"; - /// - /// Represents an HTTP POST protocol method that is used to post a new entity as an addition to a URI. - /// - public const string POST = "POST"; - /// - /// Represents an HTTP PATCH protocol method that is used to patch an existing entity identified by a URI. - /// - public const string PATCH = "PATCH"; - /// - /// Represents an HTTP DELETE protocol method that is used to delete an existing entity identified by a URI. - /// - public const string DELETE = "DELETE"; - - - internal const string DOWNLOAD = "DOWNLOAD"; - - internal const string UPLOAD = "UPLOAD"; - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/IRedmineApiClient.cs b/src/redmine-net-api/Net/IRedmineApiClient.cs deleted file mode 100644 index f9ffc4f8..00000000 --- a/src/redmine-net-api/Net/IRedmineApiClient.cs +++ /dev/null @@ -1,48 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System.Threading; -#if!(NET20) -using System.Threading.Tasks; -#endif - -namespace Redmine.Net.Api.Net; - -/// -/// -/// -internal interface IRedmineApiClient -{ - ApiResponseMessage Get(string address, RequestOptions requestOptions = null); - ApiResponseMessage GetPaged(string address, RequestOptions requestOptions = null); - ApiResponseMessage Create(string address, string payload, RequestOptions requestOptions = null); - ApiResponseMessage Update(string address, string payload, RequestOptions requestOptions = null); - ApiResponseMessage Patch(string address, string payload, RequestOptions requestOptions = null); - ApiResponseMessage Delete(string address, RequestOptions requestOptions = null); - ApiResponseMessage Upload(string address, byte[] data, RequestOptions requestOptions = null); - ApiResponseMessage Download(string address, RequestOptions requestOptions = null); - - #if !(NET20) - Task GetAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task GetPagedAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task CreateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task UpdateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task PatchAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task DeleteAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task UploadFileAsync(string address, byte[] data, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - Task DownloadAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); - #endif -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/IRedmineApiClientOptions.cs b/src/redmine-net-api/Net/IRedmineApiClientOptions.cs deleted file mode 100644 index 3a11601f..00000000 --- a/src/redmine-net-api/Net/IRedmineApiClientOptions.cs +++ /dev/null @@ -1,196 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Cache; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; - -namespace Redmine.Net.Api.Net -{ - /// - /// - /// - public interface IRedmineApiClientOptions - { - /// - /// - /// - bool? AutoRedirect { get; set; } - - /// - /// - /// - CookieContainer CookieContainer { get; set; } - - /// - /// - /// - DecompressionMethods? DecompressionFormat { get; set; } - - /// - /// - /// - ICredentials Credentials { get; set; } - - /// - /// - /// - Dictionary DefaultHeaders { get; set; } - - /// - /// - /// - IWebProxy Proxy { get; set; } - - /// - /// - /// - bool? KeepAlive { get; set; } - - /// - /// - /// - int? MaxAutomaticRedirections { get; set; } - - /// - /// - /// - long? MaxRequestContentBufferSize { get; set; } - - /// - /// - /// - long? MaxResponseContentBufferSize { get; set; } - - /// - /// - /// - int? MaxConnectionsPerServer { get; set; } - - /// - /// - /// - int? MaxResponseHeadersLength { get; set; } - - /// - /// - /// - bool? PreAuthenticate { get; set; } - - /// - /// - /// - RequestCachePolicy RequestCachePolicy { get; set; } - - /// - /// - /// - string Scheme { get; set; } - - /// - /// - /// - RemoteCertificateValidationCallback ServerCertificateValidationCallback { get; set; } - - /// - /// - /// - TimeSpan? Timeout { get; set; } - - /// - /// - /// - bool? UnsafeAuthenticatedConnectionSharing { get; set; } - - /// - /// - /// - string UserAgent { get; set; } - - /// - /// - /// - bool? UseCookies { get; set; } - - /// - /// - /// - bool? UseDefaultCredentials { get; set; } - - /// - /// - /// - bool? UseProxy { get; set; } - - /// - /// - /// - /// Only HTTP/1.0 and HTTP/1.1 version requests are currently supported. - Version ProtocolVersion { get; set; } - - /// - /// - /// - bool CheckCertificateRevocationList { get; set; } - - /// - /// - /// - int? DefaultConnectionLimit { get; set; } - - /// - /// - /// - int? DnsRefreshTimeout { get; set; } - - /// - /// - /// - bool? EnableDnsRoundRobin { get; set; } - - /// - /// - /// - int? MaxServicePoints { get; set; } - - /// - /// - /// - int? MaxServicePointIdleTime { get; set; } - - /// - /// - /// - SecurityProtocolType? SecurityProtocolType { get; set; } - - #if NET40_OR_GREATER || NETCOREAPP - /// - /// - /// - public X509CertificateCollection ClientCertificates { get; set; } - #endif - - #if(NET46_OR_GREATER || NETCOREAPP) - /// - /// - /// - public bool? ReusePort { get; set; } - #endif - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/RedmineApiUrls.cs b/src/redmine-net-api/Net/Internal/RedmineApiUrls.cs similarity index 97% rename from src/redmine-net-api/Net/RedmineApiUrls.cs rename to src/redmine-net-api/Net/Internal/RedmineApiUrls.cs index 54a9d452..8fa0b3a1 100644 --- a/src/redmine-net-api/Net/RedmineApiUrls.cs +++ b/src/redmine-net-api/Net/Internal/RedmineApiUrls.cs @@ -18,10 +18,11 @@ limitations under the License. using System.Collections.Generic; using Redmine.Net.Api.Exceptions; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; using Redmine.Net.Api.Types; using Version = Redmine.Net.Api.Types.Version; -namespace Redmine.Net.Api.Net +namespace Redmine.Net.Api.Net.Internal { internal sealed class RedmineApiUrls { @@ -31,6 +32,7 @@ internal sealed class RedmineApiUrls { {typeof(Attachment), RedmineKeys.ATTACHMENTS}, {typeof(CustomField), RedmineKeys.CUSTOM_FIELDS}, + {typeof(DocumentCategory), RedmineKeys.ENUMERATION_DOCUMENT_CATEGORIES}, {typeof(Group), RedmineKeys.GROUPS}, {typeof(Issue), RedmineKeys.ISSUES}, {typeof(IssueCategory), RedmineKeys.ISSUE_CATEGORIES}, @@ -150,7 +152,7 @@ internal string CreateEntityFragment(Type type, string ownerId = null) if (type == typeof(Upload)) { - return $"{RedmineKeys.UPLOADS}.{Format}"; + return UploadFragment(ownerId); //$"{RedmineKeys.UPLOADS}.{Format}"; } if (type == typeof(Attachment) || type == typeof(Attachments)) diff --git a/src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs b/src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs similarity index 77% rename from src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs rename to src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs index 4312ba9e..753b439d 100644 --- a/src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs +++ b/src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs @@ -14,10 +14,7 @@ You may obtain a copy of the License at limitations under the License. */ -using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Extensions; - -namespace Redmine.Net.Api.Net; +namespace Redmine.Net.Api.Net.Internal; internal static class RedmineApiUrlsExtensions { @@ -63,21 +60,11 @@ public static string ProjectRepositoryRemoveRelatedIssue(this RedmineApiUrls red public static string ProjectNews(this RedmineApiUrls redmineApiUrls, string projectIdentifier) { - if (projectIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(projectIdentifier)}' is null or whitespace"); - } - - return $"{RedmineKeys.PROJECT}/{projectIdentifier}/{RedmineKeys.NEWS}.{redmineApiUrls.Format}"; + return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.NEWS}.{redmineApiUrls.Format}"; } public static string ProjectMemberships(this RedmineApiUrls redmineApiUrls, string projectIdentifier) { - if (projectIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(projectIdentifier)}' is null or whitespace"); - } - return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.MEMBERSHIPS}.{redmineApiUrls.Format}"; } @@ -118,61 +105,26 @@ public static string ProjectWikis(this RedmineApiUrls redmineApiUrls, string pro public static string IssueWatcherAdd(this RedmineApiUrls redmineApiUrls, string issueIdentifier) { - if (issueIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(issueIdentifier)}' is null or whitespace"); - } - return $"{RedmineKeys.ISSUES}/{issueIdentifier}/{RedmineKeys.WATCHERS}.{redmineApiUrls.Format}"; } public static string IssueWatcherRemove(this RedmineApiUrls redmineApiUrls, string issueIdentifier, string userId) { - if (issueIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(issueIdentifier)}' is null or whitespace"); - } - - if (userId.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(userId)}' is null or whitespace"); - } - return $"{RedmineKeys.ISSUES}/{issueIdentifier}/{RedmineKeys.WATCHERS}/{userId}.{redmineApiUrls.Format}"; } public static string GroupUserAdd(this RedmineApiUrls redmineApiUrls, string groupIdentifier) { - if (groupIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(groupIdentifier)}' is null or whitespace"); - } - return $"{RedmineKeys.GROUPS}/{groupIdentifier}/{RedmineKeys.USERS}.{redmineApiUrls.Format}"; } public static string GroupUserRemove(this RedmineApiUrls redmineApiUrls, string groupIdentifier, string userId) { - if (groupIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(groupIdentifier)}' is null or whitespace"); - } - - if (userId.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(userId)}' is null or whitespace"); - } - return $"{RedmineKeys.GROUPS}/{groupIdentifier}/{RedmineKeys.USERS}/{userId}.{redmineApiUrls.Format}"; } public static string AttachmentUpdate(this RedmineApiUrls redmineApiUrls, string issueIdentifier) { - if (issueIdentifier.IsNullOrWhiteSpace()) - { - throw new RedmineException($"Argument '{nameof(issueIdentifier)}' is null or whitespace"); - } - return $"{RedmineKeys.ATTACHMENTS}/{RedmineKeys.ISSUES}/{issueIdentifier}.{redmineApiUrls.Format}"; } diff --git a/src/redmine-net-api/Net/WebClient/Extensions/NameValueCollectionExtensions.cs b/src/redmine-net-api/Net/WebClient/Extensions/NameValueCollectionExtensions.cs deleted file mode 100644 index 6d573e17..00000000 --- a/src/redmine-net-api/Net/WebClient/Extensions/NameValueCollectionExtensions.cs +++ /dev/null @@ -1,140 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System.Collections.Specialized; -using System.Globalization; -using System.Text; - -namespace Redmine.Net.Api.Extensions -{ - /// - /// - /// - public static class NameValueCollectionExtensions - { - /// - /// Gets the parameter value. - /// - /// The parameters. - /// Name of the parameter. - /// - public static string GetParameterValue(this NameValueCollection parameters, string parameterName) - { - return GetValue(parameters, parameterName); - } - - /// - /// Gets the parameter value. - /// - /// The parameters. - /// Name of the parameter. - /// - public static string GetValue(this NameValueCollection parameters, string key) - { - if (parameters == null) - { - return null; - } - - var value = parameters.Get(key); - - return value.IsNullOrWhiteSpace() ? null : value; - } - - /// - /// - /// - /// - /// - public static string ToQueryString(this NameValueCollection requestParameters) - { - if (requestParameters == null || requestParameters.Count == 0) - { - return null; - } - - var delimiter = string.Empty; - - var stringBuilder = new StringBuilder(); - - for (var index = 0; index < requestParameters.Count; ++index) - { - stringBuilder - .Append(delimiter) - .Append(requestParameters.AllKeys[index].ToString(CultureInfo.InvariantCulture)) - .Append('=') - .Append(requestParameters[index].ToString(CultureInfo.InvariantCulture)); - delimiter = "&"; - } - - var queryString = stringBuilder.ToString(); - - stringBuilder.Length = 0; - - return queryString; - } - - internal static NameValueCollection AddPagingParameters(this NameValueCollection parameters, int pageSize, int offset) - { - parameters ??= new NameValueCollection(); - - if(pageSize <= 0) - { - pageSize = RedmineConstants.DEFAULT_PAGE_SIZE_VALUE; - } - - if(offset < 0) - { - offset = 0; - } - - parameters.Set(RedmineKeys.LIMIT, pageSize.ToString(CultureInfo.InvariantCulture)); - parameters.Set(RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture)); - - return parameters; - } - - internal static NameValueCollection AddParamsIfExist(this NameValueCollection parameters, string[] include) - { - if (include is not {Length: > 0}) - { - return parameters; - } - - parameters ??= new NameValueCollection(); - - parameters.Add(RedmineKeys.INCLUDE, string.Join(",", include)); - - return parameters; - } - - internal static void AddIfNotNull(this NameValueCollection nameValueCollection, string key, string value) - { - if (!value.IsNullOrWhiteSpace()) - { - nameValueCollection.Add(key, value); - } - } - - internal static void AddIfNotNull(this NameValueCollection nameValueCollection, string key, bool? value) - { - if (value.HasValue) - { - nameValueCollection.Add(key, value.Value.ToString(CultureInfo.InvariantCulture)); - } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/Extensions/WebExtensions.cs b/src/redmine-net-api/Net/WebClient/Extensions/WebExtensions.cs deleted file mode 100644 index 3a3b1a2a..00000000 --- a/src/redmine-net-api/Net/WebClient/Extensions/WebExtensions.cs +++ /dev/null @@ -1,146 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Text; -using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Serialization; -using Redmine.Net.Api.Types; - -namespace Redmine.Net.Api.Extensions -{ - /// - /// - /// - internal static class WebExceptionExtensions - { - /// - /// Handles the web exception. - /// - /// The exception. - /// - /// Timeout! - /// Bad domain name! - /// - /// - /// - /// - /// The page that you are trying to update is staled! - /// - /// - /// - public static void HandleWebException(this WebException exception, IRedmineSerializer serializer) - { - if (exception == null) - { - return; - } - - var innerException = exception.InnerException ?? exception; - - switch (exception.Status) - { - case WebExceptionStatus.Timeout: - throw new RedmineTimeoutException(nameof(WebExceptionStatus.Timeout), innerException); - case WebExceptionStatus.NameResolutionFailure: - throw new NameResolutionFailureException("Bad domain name.", innerException); - - case WebExceptionStatus.ProtocolError: - { - var response = (HttpWebResponse)exception.Response; - switch ((int)response.StatusCode) - { - case (int)HttpStatusCode.NotFound: - throw new NotFoundException(response.StatusDescription, innerException); - - case (int)HttpStatusCode.Unauthorized: - throw new UnauthorizedException(response.StatusDescription, innerException); - - case (int)HttpStatusCode.Forbidden: - throw new ForbiddenException(response.StatusDescription, innerException); - - case (int)HttpStatusCode.Conflict: - throw new ConflictException("The page that you are trying to update is staled!", innerException); - - case 422: - RedmineException redmineException; - var errors = GetRedmineExceptions(exception.Response, serializer); - - if (errors != null) - { - var sb = new StringBuilder(); - foreach (var error in errors) - { - sb.Append(error.Info).Append(Environment.NewLine); - } - - redmineException = new RedmineException($"Invalid or missing attribute parameters: {sb}", innerException, "Unprocessable Content"); - sb.Length = 0; - } - else - { - redmineException = new RedmineException("Invalid or missing attribute parameters", innerException); - } - - throw redmineException; - - case (int)HttpStatusCode.NotAcceptable: - throw new NotAcceptableException(response.StatusDescription, innerException); - - default: - throw new RedmineException(response.StatusDescription, innerException); - } - } - - default: - throw new RedmineException(exception.Message, innerException); - } - } - - /// - /// Gets the redmine exceptions. - /// - /// The web response. - /// - /// - private static IEnumerable GetRedmineExceptions(this WebResponse webResponse, IRedmineSerializer serializer) - { - using (var responseStream = webResponse.GetResponseStream()) - { - if (responseStream == null) - { - return null; - } - - using (var streamReader = new StreamReader(responseStream)) - { - var responseContent = streamReader.ReadToEnd(); - - if (responseContent.IsNullOrWhiteSpace()) - { - return null; - } - - var result = serializer.DeserializeToPagedResults(responseContent); - return result.Items; - } - } - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/IRedmineWebClientObsolete.cs b/src/redmine-net-api/Net/WebClient/IRedmineWebClientObsolete.cs deleted file mode 100644 index 3b7c1bd9..00000000 --- a/src/redmine-net-api/Net/WebClient/IRedmineWebClientObsolete.cs +++ /dev/null @@ -1,90 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Specialized; -using System.Net; -using System.Net.Cache; - -namespace Redmine.Net.Api.Types -{ - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public interface IRedmineWebClient - { - /// - /// - /// - string UserAgent { get; set; } - - /// - /// - /// - bool UseProxy { get; set; } - - /// - /// - /// - bool UseCookies { get; set; } - - /// - /// - /// - TimeSpan? Timeout { get; set; } - - /// - /// - /// - CookieContainer CookieContainer { get; set; } - - /// - /// - /// - bool PreAuthenticate { get; set; } - - /// - /// - /// - bool KeepAlive { get; set; } - - /// - /// - /// - NameValueCollection QueryString { get; } - - /// - /// - /// - bool UseDefaultCredentials { get; set; } - - /// - /// - /// - ICredentials Credentials { get; set; } - - /// - /// - /// - IWebProxy Proxy { get; set; } - - /// - /// - /// - RequestCachePolicy CachePolicy { get; set; } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/IRedmineWebClientOptions.cs b/src/redmine-net-api/Net/WebClient/IRedmineWebClientOptions.cs deleted file mode 100644 index 809d9afb..00000000 --- a/src/redmine-net-api/Net/WebClient/IRedmineWebClientOptions.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Cache; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; - -namespace Redmine.Net.Api.Net.WebClient; - -/// -/// -/// -public interface IRedmineWebClientOptions : IRedmineApiClientOptions -{ -#if NET40_OR_GREATER || NETCOREAPP - /// - /// - /// - public X509CertificateCollection ClientCertificates { get; set; } -#endif - - /// - /// - /// - int? DefaultConnectionLimit { get; set; } - - /// - /// - /// - Dictionary DefaultHeaders { get; set; } - - /// - /// - /// - int? DnsRefreshTimeout { get; set; } - - /// - /// - /// - bool? EnableDnsRoundRobin { get; set; } - - /// - /// - /// - bool? KeepAlive { get; set; } - - /// - /// - /// - int? MaxServicePoints { get; set; } - - /// - /// - /// - int? MaxServicePointIdleTime { get; set; } - - /// - /// - /// - RequestCachePolicy RequestCachePolicy { get; set; } - -#if(NET46_OR_GREATER || NETCOREAPP) - /// - /// - /// - public bool? ReusePort { get; set; } -#endif - - /// - /// - /// - RemoteCertificateValidationCallback ServerCertificateValidationCallback { get; set; } - - /// - /// - /// - bool? UnsafeAuthenticatedConnectionSharing { get; set; } - - /// - /// - /// - /// Only HTTP/1.0 and HTTP/1.1 version requests are currently supported. - Version ProtocolVersion { get; set; } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/InternalRedmineApiWebClient.cs b/src/redmine-net-api/Net/WebClient/InternalRedmineApiWebClient.cs deleted file mode 100644 index df94c7fa..00000000 --- a/src/redmine-net-api/Net/WebClient/InternalRedmineApiWebClient.cs +++ /dev/null @@ -1,362 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Specialized; -using System.Net; -using System.Text; -using System.Threading; -using Redmine.Net.Api.Authentication; -#if!(NET20) -using System.Threading.Tasks; -#endif -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Net.WebClient.MessageContent; -using Redmine.Net.Api.Serialization; - -namespace Redmine.Net.Api.Net.WebClient -{ - /// - /// - /// - internal sealed class InternalRedmineApiWebClient : IRedmineApiClient - { - private static readonly byte[] EmptyBytes = Encoding.UTF8.GetBytes(string.Empty); - private readonly Func _webClientFunc; - private readonly IRedmineAuthentication _credentials; - private readonly IRedmineSerializer _serializer; - - public InternalRedmineApiWebClient(RedmineManagerOptions redmineManagerOptions) - : this(() => new InternalWebClient(redmineManagerOptions), redmineManagerOptions.Authentication, redmineManagerOptions.Serializer) - { - ConfigureServicePointManager(redmineManagerOptions.WebClientOptions); - } - - public InternalRedmineApiWebClient( - Func webClientFunc, - IRedmineAuthentication authentication, - IRedmineSerializer serializer) - { - _webClientFunc = webClientFunc; - _credentials = authentication; - _serializer = serializer; - } - - private static void ConfigureServicePointManager(IRedmineWebClientOptions webClientOptions) - { - if (webClientOptions == null) - { - return; - } - - if (webClientOptions.MaxServicePoints.HasValue) - { - ServicePointManager.MaxServicePoints = webClientOptions.MaxServicePoints.Value; - } - - if (webClientOptions.MaxServicePointIdleTime.HasValue) - { - ServicePointManager.MaxServicePointIdleTime = webClientOptions.MaxServicePointIdleTime.Value; - } - - ServicePointManager.SecurityProtocol = webClientOptions.SecurityProtocolType ?? ServicePointManager.SecurityProtocol; - - if (webClientOptions.DefaultConnectionLimit.HasValue) - { - ServicePointManager.DefaultConnectionLimit = webClientOptions.DefaultConnectionLimit.Value; - } - - if (webClientOptions.DnsRefreshTimeout.HasValue) - { - ServicePointManager.DnsRefreshTimeout = webClientOptions.DnsRefreshTimeout.Value; - } - - ServicePointManager.CheckCertificateRevocationList = webClientOptions.CheckCertificateRevocationList; - - if (webClientOptions.EnableDnsRoundRobin.HasValue) - { - ServicePointManager.EnableDnsRoundRobin = webClientOptions.EnableDnsRoundRobin.Value; - } - - #if(NET46_OR_GREATER || NETCOREAPP) - if (webClientOptions.ReusePort.HasValue) - { - ServicePointManager.ReusePort = webClientOptions.ReusePort.Value; - } - #endif - } - - public ApiResponseMessage Get(string address, RequestOptions requestOptions = null) - { - return HandleRequest(address, HttpVerbs.GET, requestOptions); - } - - public ApiResponseMessage GetPaged(string address, RequestOptions requestOptions = null) - { - return Get(address, requestOptions); - } - - public ApiResponseMessage Create(string address, string payload, RequestOptions requestOptions = null) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return HandleRequest(address, HttpVerbs.POST, requestOptions, content); - } - - public ApiResponseMessage Update(string address, string payload, RequestOptions requestOptions = null) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return HandleRequest(address, HttpVerbs.PUT, requestOptions, content); - } - - public ApiResponseMessage Patch(string address, string payload, RequestOptions requestOptions = null) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return HandleRequest(address, HttpVerbs.PATCH, requestOptions, content); - } - - public ApiResponseMessage Delete(string address, RequestOptions requestOptions = null) - { - return HandleRequest(address, HttpVerbs.DELETE, requestOptions); - } - - public ApiResponseMessage Download(string address, RequestOptions requestOptions = null) - { - return HandleRequest(address, HttpVerbs.DOWNLOAD, requestOptions); - } - - public ApiResponseMessage Upload(string address, byte[] data, RequestOptions requestOptions = null) - { - var content = new StreamApiRequestMessageContent(data); - return HandleRequest(address, HttpVerbs.POST, requestOptions, content); - } - - #if !(NET20) - public async Task GetAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return await HandleRequestAsync(address, HttpVerbs.GET, requestOptions, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public Task GetPagedAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return GetAsync(address, requestOptions, cancellationToken); - } - - public async Task CreateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return await HandleRequestAsync(address, HttpVerbs.POST, requestOptions, content, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public async Task UpdateAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return await HandleRequestAsync(address, HttpVerbs.PUT, requestOptions, content, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public async Task UploadFileAsync(string address, byte[] data, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var content = new StreamApiRequestMessageContent(data); - return await HandleRequestAsync(address, HttpVerbs.POST, requestOptions, content, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public async Task PatchAsync(string address, string payload, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var content = new StringApiRequestMessageContent(payload, GetContentType(_serializer)); - return await HandleRequestAsync(address, HttpVerbs.PATCH, requestOptions, content, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public async Task DeleteAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return await HandleRequestAsync(address, HttpVerbs.DELETE, requestOptions, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - public async Task DownloadAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return await HandleRequestAsync(address, HttpVerbs.DOWNLOAD, requestOptions, cancellationToken:cancellationToken).ConfigureAwait(false); - } - - private Task HandleRequestAsync(string address, string verb, RequestOptions requestOptions = null, ApiRequestMessageContent content = null, CancellationToken cancellationToken = default) - { - return SendAsync(CreateRequestMessage(address, verb, requestOptions, content), cancellationToken); - } - - private async Task SendAsync(ApiRequestMessage requestMessage, CancellationToken cancellationToken) - { - System.Net.WebClient webClient = null; - byte[] response = null; - NameValueCollection responseHeaders = null; - try - { - webClient = _webClientFunc(); - - cancellationToken.Register(webClient.CancelAsync); - - SetWebClientHeaders(webClient, requestMessage); - - if(IsGetOrDownload(requestMessage.Method)) - { - response = await webClient.DownloadDataTaskAsync(requestMessage.RequestUri).ConfigureAwait(false); - } - else - { - byte[] payload; - if (requestMessage.Content != null) - { - webClient.Headers.Add(HttpRequestHeader.ContentType, requestMessage.Content.ContentType); - payload = requestMessage.Content.Body; - } - else - { - payload = EmptyBytes; - } - - response = await webClient.UploadDataTaskAsync(requestMessage.RequestUri, requestMessage.Method, payload).ConfigureAwait(false); - } - - responseHeaders = webClient.ResponseHeaders; - } - catch (WebException ex) when (ex.Status == WebExceptionStatus.RequestCanceled) - { - //TODO: Handle cancellation... - } - catch (WebException webException) - { - webException.HandleWebException(_serializer); - } - finally - { - webClient?.Dispose(); - } - - return new ApiResponseMessage() - { - Headers = responseHeaders, - Content = response - }; - } - #endif - - - private static ApiRequestMessage CreateRequestMessage(string address, string verb, RequestOptions requestOptions = null, ApiRequestMessageContent content = null) - { - var req = new ApiRequestMessage() - { - RequestUri = address, - Method = verb, - }; - - if (requestOptions != null) - { - req.QueryString = requestOptions.QueryString; - req.ImpersonateUser = requestOptions.ImpersonateUser; - } - - if (content != null) - { - req.Content = content; - } - - return req; - } - - private ApiResponseMessage HandleRequest(string address, string verb, RequestOptions requestOptions = null, ApiRequestMessageContent content = null) - { - return Send(CreateRequestMessage(address, verb, requestOptions, content)); - } - - private ApiResponseMessage Send(ApiRequestMessage requestMessage) - { - System.Net.WebClient webClient = null; - byte[] response = null; - NameValueCollection responseHeaders = null; - - try - { - webClient = _webClientFunc(); - SetWebClientHeaders(webClient, requestMessage); - - if (IsGetOrDownload(requestMessage.Method)) - { - response = webClient.DownloadData(requestMessage.RequestUri); - } - else - { - byte[] payload; - if (requestMessage.Content != null) - { - webClient.Headers.Add(HttpRequestHeader.ContentType, requestMessage.Content.ContentType); - payload = requestMessage.Content.Body; - } - else - { - payload = EmptyBytes; - } - - response = webClient.UploadData(requestMessage.RequestUri, requestMessage.Method, payload); - } - - responseHeaders = webClient.ResponseHeaders; - } - catch (WebException webException) - { - webException.HandleWebException(_serializer); - } - finally - { - webClient?.Dispose(); - } - - return new ApiResponseMessage() - { - Headers = responseHeaders, - Content = response - }; - } - - private void SetWebClientHeaders(System.Net.WebClient webClient, ApiRequestMessage requestMessage) - { - if (requestMessage.QueryString != null) - { - webClient.QueryString = requestMessage.QueryString; - } - - switch (_credentials) - { - case RedmineApiKeyAuthentication: - webClient.Headers.Add(_credentials.AuthenticationType,_credentials.Token); - break; - case RedmineBasicAuthentication: - webClient.Headers.Add("Authorization", $"{_credentials.AuthenticationType} {_credentials.Token}"); - break; - } - - if (!requestMessage.ImpersonateUser.IsNullOrWhiteSpace()) - { - webClient.Headers.Add(RedmineConstants.IMPERSONATE_HEADER_KEY, requestMessage.ImpersonateUser); - } - } - - private static bool IsGetOrDownload(string method) - { - return method is HttpVerbs.GET or HttpVerbs.DOWNLOAD; - } - - private static string GetContentType(IRedmineSerializer serializer) - { - return serializer.Format == RedmineConstants.XML ? RedmineConstants.CONTENT_TYPE_APPLICATION_XML : RedmineConstants.CONTENT_TYPE_APPLICATION_JSON; - } - } -} diff --git a/src/redmine-net-api/Net/WebClient/MessageContent/ByteArrayApiRequestMessageContent.cs b/src/redmine-net-api/Net/WebClient/MessageContent/ByteArrayApiRequestMessageContent.cs deleted file mode 100644 index a1456ad2..00000000 --- a/src/redmine-net-api/Net/WebClient/MessageContent/ByteArrayApiRequestMessageContent.cs +++ /dev/null @@ -1,25 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Net.WebClient.MessageContent; - -internal class ByteArrayApiRequestMessageContent : ApiRequestMessageContent -{ - public ByteArrayApiRequestMessageContent(byte[] content) - { - Body = content; - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/MessageContent/StreamApiRequestMessageContent.cs b/src/redmine-net-api/Net/WebClient/MessageContent/StreamApiRequestMessageContent.cs deleted file mode 100644 index ed49becf..00000000 --- a/src/redmine-net-api/Net/WebClient/MessageContent/StreamApiRequestMessageContent.cs +++ /dev/null @@ -1,25 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -namespace Redmine.Net.Api.Net.WebClient.MessageContent; - -internal sealed class StreamApiRequestMessageContent : ByteArrayApiRequestMessageContent -{ - public StreamApiRequestMessageContent(byte[] content) : base(content) - { - ContentType = RedmineConstants.CONTENT_TYPE_APPLICATION_STREAM; - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/MessageContent/StringApiRequestMessageContent.cs b/src/redmine-net-api/Net/WebClient/MessageContent/StringApiRequestMessageContent.cs deleted file mode 100644 index 3a1d7590..00000000 --- a/src/redmine-net-api/Net/WebClient/MessageContent/StringApiRequestMessageContent.cs +++ /dev/null @@ -1,41 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Text; -using Redmine.Net.Api.Internals; - -namespace Redmine.Net.Api.Net.WebClient.MessageContent; - -internal sealed class StringApiRequestMessageContent : ByteArrayApiRequestMessageContent -{ - private static readonly Encoding DefaultStringEncoding = Encoding.UTF8; - - public StringApiRequestMessageContent(string content, string mediaType) : this(content, mediaType, DefaultStringEncoding) - { - } - - public StringApiRequestMessageContent(string content, string mediaType, Encoding encoding) : base(GetContentByteArray(content, encoding)) - { - ContentType = mediaType; - } - - private static byte[] GetContentByteArray(string content, Encoding encoding) - { - ArgumentNullThrowHelper.ThrowIfNull(content, nameof(content)); - return (encoding ?? DefaultStringEncoding).GetBytes(content); - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Net/WebClient/RedmineWebClient.Obsolete.cs b/src/redmine-net-api/Net/WebClient/RedmineWebClient.Obsolete.cs deleted file mode 100644 index 0276931c..00000000 --- a/src/redmine-net-api/Net/WebClient/RedmineWebClient.Obsolete.cs +++ /dev/null @@ -1,260 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Net; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Serialization; - -namespace Redmine.Net.Api -{ - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - #pragma warning disable SYSLIB0014 - public class RedmineWebClient : WebClient - { - private string redirectUrl = string.Empty; - - /// - /// - /// - public string UserAgent { get; set; } - - /// - /// Gets or sets a value indicating whether [use proxy]. - /// - /// - /// true if [use proxy]; otherwise, false. - /// - public bool UseProxy { get; set; } - - /// - /// Gets or sets a value indicating whether [use cookies]. - /// - /// - /// true if [use cookies]; otherwise, false. - /// - public bool UseCookies { get; set; } - - /// - /// in milliseconds - /// - /// - /// The timeout. - /// - public TimeSpan? Timeout { get; set; } - - /// - /// Gets or sets the cookie container. - /// - /// - /// The cookie container. - /// - public CookieContainer CookieContainer { get; set; } - - /// - /// Gets or sets a value indicating whether [pre authenticate]. - /// - /// - /// true if [pre authenticate]; otherwise, false. - /// - public bool PreAuthenticate { get; set; } - - /// - /// Gets or sets a value indicating whether [keep alive]. - /// - /// - /// true if [keep alive]; otherwise, false. - /// - public bool KeepAlive { get; set; } - - /// - /// - /// - public string Scheme { get; set; } = "https"; - - /// - /// - /// - public RedirectType Redirect { get; set; } - - /// - /// - /// - internal IRedmineSerializer RedmineSerializer { get; set; } - - /// - /// Returns a object for the specified resource. - /// - /// A that identifies the resource to request. - /// - /// A new object for the specified resource. - /// - protected override WebRequest GetWebRequest(Uri address) - { - var wr = base.GetWebRequest(address); - - if (!(wr is HttpWebRequest httpWebRequest)) - { - return base.GetWebRequest(address); - } - - httpWebRequest.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | - DecompressionMethods.None; - - if (UseCookies) - { - httpWebRequest.Headers.Add(HttpRequestHeader.Cookie, "redmineCookie"); - httpWebRequest.CookieContainer = CookieContainer; - } - - httpWebRequest.KeepAlive = KeepAlive; - httpWebRequest.CachePolicy = CachePolicy; - - if (Timeout != null) - { - httpWebRequest.Timeout = (int)Timeout.Value.TotalMilliseconds; - } - - return httpWebRequest; - } - - /// - /// - /// - /// - /// - protected override WebResponse GetWebResponse(WebRequest request) - { - WebResponse response = null; - - try - { - response = base.GetWebResponse(request); - } - catch (WebException webException) - { - webException.HandleWebException(RedmineSerializer); - } - - switch (response) - { - case null: - return null; - case HttpWebResponse _: - HandleRedirect(request, response); - HandleCookies(request, response); - break; - } - - return response; - } - - /// - /// Handles redirect response if needed - /// - /// Request - /// Response - protected void HandleRedirect(WebRequest request, WebResponse response) - { - var webResponse = response as HttpWebResponse; - - if (Redirect == RedirectType.None) - { - return; - } - - if (webResponse == null) - { - return; - } - - var code = webResponse.StatusCode; - - if (code == HttpStatusCode.Found || code == HttpStatusCode.SeeOther || code == HttpStatusCode.MovedPermanently || code == HttpStatusCode.Moved) - { - redirectUrl = webResponse.Headers["Location"]; - - var isAbsoluteUri = new Uri(redirectUrl).IsAbsoluteUri; - - if (!isAbsoluteUri) - { - var webRequest = request as HttpWebRequest; - var host = webRequest?.Headers["Host"] ?? string.Empty; - - if (Redirect == RedirectType.All) - { - host = $"{host}{webRequest?.RequestUri.AbsolutePath}"; - - host = host.Substring(0, host.LastIndexOf('/')); - } - - // Have to make sure that the "/" symbol is between the "host" and "redirect" strings - #if NET5_0_OR_GREATER - if (!redirectUrl.StartsWith('/') && !host.EndsWith('/')) - #else - if (!redirectUrl.StartsWith("/", StringComparison.OrdinalIgnoreCase) && !host.EndsWith("/", StringComparison.OrdinalIgnoreCase)) - #endif - { - redirectUrl = $"/{redirectUrl}"; - } - - redirectUrl = $"{host}{redirectUrl}"; - } - - if (!redirectUrl.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase)) - { - redirectUrl = $"{Scheme}://{redirectUrl}"; - } - } - else - { - redirectUrl = string.Empty; - } - } - - /// - /// Handles additional cookies - /// - /// Request - /// Response - protected void HandleCookies(WebRequest request, WebResponse response) - { - if (!(response is HttpWebResponse webResponse)) return; - - var webRequest = request as HttpWebRequest; - - if (webResponse.Cookies.Count <= 0) return; - - var col = new CookieCollection(); - - foreach (Cookie c in webResponse.Cookies) - { - col.Add(new Cookie(c.Name, c.Value, c.Path, webRequest?.Headers["Host"])); - } - - if (CookieContainer == null) - { - CookieContainer = new CookieContainer(); - } - - CookieContainer.Add(col); - } - } - #pragma warning restore SYSLIB0014 -} \ No newline at end of file diff --git a/src/redmine-net-api/RedmineManagerOptions.cs b/src/redmine-net-api/Options/RedmineManagerOptions.cs similarity index 65% rename from src/redmine-net-api/RedmineManagerOptions.cs rename to src/redmine-net-api/Options/RedmineManagerOptions.cs index 3801922b..7093c073 100644 --- a/src/redmine-net-api/RedmineManagerOptions.cs +++ b/src/redmine-net-api/Options/RedmineManagerOptions.cs @@ -17,11 +17,16 @@ limitations under the License. using System; using System.Net; using Redmine.Net.Api.Authentication; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Net.WebClient; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Clients.WebClient; +using Redmine.Net.Api.Logging; using Redmine.Net.Api.Serialization; +#if !NET20 +using System.Net.Http; +using Redmine.Net.Api.Http.Clients.HttpClient; +#endif -namespace Redmine.Net.Api +namespace Redmine.Net.Api.Options { /// /// @@ -53,6 +58,23 @@ internal sealed class RedmineManagerOptions /// public IRedmineAuthentication Authentication { get; init; } + /// + /// Gets or sets the version of the Redmine server to which this client will connect. + /// + public Version RedmineVersion { get; init; } + + public IRedmineLogger Logger { get; init; } + + /// + /// Gets or sets additional logging configuration options + /// + public RedmineLoggingOptions LoggingOptions { get; init; } = new RedmineLoggingOptions(); + + /// + /// Gets or sets the settings for configuring the Redmine http client. + /// + public IRedmineApiClientOptions ApiClientOptions { get; set; } + /// /// Gets or sets a custom function that creates and returns a specialized instance of the WebClient class. /// @@ -61,13 +83,24 @@ internal sealed class RedmineManagerOptions /// /// Gets or sets the settings for configuring the Redmine web client. /// - public IRedmineWebClientOptions WebClientOptions { get; init; } + public RedmineWebClientOptions WebClientOptions { + get => (RedmineWebClientOptions)ApiClientOptions; + set => ApiClientOptions = value; + } + #if !NET20 /// - /// Gets or sets the version of the Redmine server to which this client will connect. + /// /// - public Version RedmineVersion { get; init; } + public HttpClient HttpClient { get; init; } - internal bool VerifyServerCert { get; init; } + /// + /// Gets or sets the settings for configuring the Redmine http client. + /// + public RedmineHttpClientOptions HttpClientOptions { + get => (RedmineHttpClientOptions)ApiClientOptions; + set => ApiClientOptions = value; + } + #endif } } \ No newline at end of file diff --git a/src/redmine-net-api/Options/RedmineManagerOptionsBuilder.cs b/src/redmine-net-api/Options/RedmineManagerOptionsBuilder.cs new file mode 100644 index 00000000..698cfb31 --- /dev/null +++ b/src/redmine-net-api/Options/RedmineManagerOptionsBuilder.cs @@ -0,0 +1,306 @@ +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +using System; +using System.Net; +#if NET462_OR_GREATER || NET +using Microsoft.Extensions.Logging; +#endif +using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Http; +#if !NET20 +using Redmine.Net.Api.Http.Clients.HttpClient; +#endif +using Redmine.Net.Api.Http.Clients.WebClient; +using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Logging; +using Redmine.Net.Api.Serialization; +#if NET40_OR_GREATER || NET +using System.Net.Http; +#endif +#if NET462_OR_GREATER || NET +#endif + +namespace Redmine.Net.Api.Options +{ + /// + /// + /// + public sealed class RedmineManagerOptionsBuilder + { + private IRedmineLogger _redmineLogger = RedmineNullLogger.Instance; + private Action _configureLoggingOptions; + + private enum ClientType + { + WebClient, + HttpClient, + } + private ClientType _clientType = ClientType.HttpClient; + + /// + /// + /// + public string Host { get; private set; } + + /// + /// + /// + public int PageSize { get; private set; } + + /// + /// Gets the current serialization type + /// + public SerializationType SerializationType { get; private set; } + + /// + /// + /// + public IRedmineAuthentication Authentication { get; private set; } + + /// + /// + /// + public IRedmineApiClientOptions ClientOptions { get; private set; } + + /// + /// + /// + public Func ClientFunc { get; private set; } + + /// + /// Gets or sets the version of the Redmine server to which this client will connect. + /// + public Version Version { get; set; } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithPageSize(int pageSize) + { + PageSize = pageSize; + return this; + } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithHost(string baseAddress) + { + Host = baseAddress; + return this; + } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithSerializationType(SerializationType serializationType) + { + SerializationType = serializationType; + return this; + } + + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithXmlSerialization() + { + SerializationType = SerializationType.Xml; + return this; + } + + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithJsonSerialization() + { + SerializationType = SerializationType.Json; + return this; + } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithApiKeyAuthentication(string apiKey) + { + Authentication = new RedmineApiKeyAuthentication(apiKey); + return this; + } + + /// + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithBasicAuthentication(string login, string password) + { + Authentication = new RedmineBasicAuthentication(login, password); + return this; + } + + /// + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithLogger(IRedmineLogger logger, Action configure = null) + { + _redmineLogger = logger ?? RedmineNullLogger.Instance; + _configureLoggingOptions = configure; + return this; + } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithVersion(Version version) + { + Version = version; + return this; + } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithWebClient(Func clientFunc) + { + _clientType = ClientType.WebClient; + ClientFunc = clientFunc; + return this; + } + + /// + /// Configures the client to use WebClient with default settings + /// + /// This builder instance for method chaining + public RedmineManagerOptionsBuilder UseWebClient(RedmineWebClientOptions clientOptions = null) + { + _clientType = ClientType.WebClient; + ClientOptions = clientOptions; + return this; + } + +#if NET40_OR_GREATER || NET + /// + /// + /// + public Func HttpClientFunc { get; private set; } + + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithHttpClient(Func clientFunc) + { + _clientType = ClientType.HttpClient; + this.HttpClientFunc = clientFunc; + return this; + } + + /// + /// Configures the client to use HttpClient with default settings + /// + /// This builder instance for method chaining + public RedmineManagerOptionsBuilder UseHttpClient(RedmineHttpClientOptions clientOptions = null) + { + _clientType = ClientType.HttpClient; + ClientOptions = clientOptions; + return this; + } + +#endif + +#if NET462_OR_GREATER || NET + /// + /// + /// + /// + /// + /// + public RedmineManagerOptionsBuilder WithLogger(ILogger logger, Action configure = null) + { + _redmineLogger = new MicrosoftLoggerRedmineAdapter(logger); + _configureLoggingOptions = configure; + return this; + } +#endif + + /// + /// + /// + /// + internal RedmineManagerOptions Build() + { +#if NET45_OR_GREATER || NET + ClientOptions ??= _clientType switch + { + ClientType.WebClient => new RedmineWebClientOptions(), + ClientType.HttpClient => new RedmineHttpClientOptions(), + _ => throw new ArgumentOutOfRangeException() + }; +#else + ClientOptions ??= new RedmineWebClientOptions(); +#endif + + var baseAddress = HostHelper.CreateRedmineUri(Host, ClientOptions.Scheme); + + var redmineLoggingOptions = ConfigureLoggingOptions(); + + var options = new RedmineManagerOptions() + { + BaseAddress = baseAddress, + PageSize = PageSize > 0 ? PageSize : RedmineConstants.DEFAULT_PAGE_SIZE_VALUE, + Serializer = RedmineSerializerFactory.CreateSerializer(SerializationType), + RedmineVersion = Version, + Authentication = Authentication ?? new RedmineNoAuthentication(), + ApiClientOptions = ClientOptions, + Logger = _redmineLogger, + LoggingOptions = redmineLoggingOptions, + }; + + return options; + } + + private RedmineLoggingOptions ConfigureLoggingOptions() + { + if (_configureLoggingOptions == null) + { + return null; + } + + var redmineLoggingOptions = new RedmineLoggingOptions(); + _configureLoggingOptions(redmineLoggingOptions); + return redmineLoggingOptions; + } + } +} \ No newline at end of file diff --git a/src/redmine-net-api/RedmineConstants.cs b/src/redmine-net-api/RedmineConstants.cs index e9d00588..fa752ce4 100644 --- a/src/redmine-net-api/RedmineConstants.cs +++ b/src/redmine-net-api/RedmineConstants.cs @@ -48,9 +48,26 @@ public static class RedmineConstants /// public const string IMPERSONATE_HEADER_KEY = "X-Redmine-Switch-User"; + /// + /// + /// + public const string AUTHORIZATION_HEADER_KEY = "Authorization"; + /// + /// + /// + public const string API_KEY_AUTHORIZATION_HEADER_KEY = "X-Redmine-API-Key"; + /// /// /// public const string XML = "xml"; + + /// + /// + /// + public const string JSON = "json"; + + internal const string USER_AGENT_HEADER_KEY = "User-Agent"; + internal const string CONTENT_TYPE_HEADER_KEY = "Content-Type"; } } \ No newline at end of file diff --git a/src/redmine-net-api/RedmineKeys.cs b/src/redmine-net-api/RedmineKeys.cs index 045533cb..b5072aa6 100644 --- a/src/redmine-net-api/RedmineKeys.cs +++ b/src/redmine-net-api/RedmineKeys.cs @@ -179,8 +179,13 @@ public static class RedmineKeys /// /// /// - public const string CUSTOM_FIELDS = "custom_fields"; + public const string CUSTOM_FIELD_VALUES = "custom_field_values"; + /// + /// + /// + public const string CUSTOM_FIELDS = "custom_fields"; + /// /// /// @@ -269,6 +274,10 @@ public static class RedmineKeys /// /// /// + public const string ENUMERATION_DOCUMENT_CATEGORIES = "enumerations/document_categories"; + /// + /// + /// public const string ENUMERATION_ISSUE_PRIORITIES = "enumerations/issue_priorities"; /// /// @@ -326,6 +335,10 @@ public static class RedmineKeys /// /// /// + public const string GENERATE_PASSWORD = "generate_password"; + /// + /// + /// public const string GROUP = "group"; /// /// @@ -383,7 +396,10 @@ public static class RedmineKeys /// /// public const string ISSUE_CATEGORY = "issue_category"; - + /// + /// + /// + public const string ISSUE_CUSTOM_FIELDS = "issue_custom_fields"; /// /// /// @@ -684,6 +700,10 @@ public static class RedmineKeys /// /// /// + public const string SEND_INFORMATION = "send_information"; + /// + /// + /// public const string SEARCH = "search"; /// /// diff --git a/src/redmine-net-api/RedmineManagerAsync.cs b/src/redmine-net-api/RedmineManager.Async.cs similarity index 95% rename from src/redmine-net-api/RedmineManagerAsync.cs rename to src/redmine-net-api/RedmineManager.Async.cs index 72e2d6a6..bf48328e 100644 --- a/src/redmine-net-api/RedmineManagerAsync.cs +++ b/src/redmine-net-api/RedmineManager.Async.cs @@ -20,11 +20,17 @@ limitations under the License. using System.Collections.Specialized; using System.Threading; using System.Threading.Tasks; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Extensions; using Redmine.Net.Api.Net; +using Redmine.Net.Api.Net.Internal; using Redmine.Net.Api.Serialization; using Redmine.Net.Api.Types; +#if!(NET45_OR_GREATER || NETCOREAPP) using TaskExtensions = Redmine.Net.Api.Extensions.TaskExtensions; +#endif namespace Redmine.Net.Api; @@ -211,15 +217,15 @@ public async Task UploadFileAsync(byte[] data, string fileName = null, R { var url = RedmineApiUrls.UploadFragment(fileName); - var response = await ApiClient.UploadFileAsync(url, data,requestOptions , cancellationToken: cancellationToken).ConfigureAwait(false); + var response = await ApiClient.UploadFileAsync(url, data, requestOptions, cancellationToken: cancellationToken).ConfigureAwait(false); return response.DeserializeTo(Serializer); } /// - public async Task DownloadFileAsync(string address, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + public async Task DownloadFileAsync(string address, RequestOptions requestOptions = null, IProgress progress = null, CancellationToken cancellationToken = default) { - var response = await ApiClient.DownloadAsync(address, requestOptions,cancellationToken: cancellationToken).ConfigureAwait(false); + var response = await ApiClient.DownloadAsync(address, requestOptions, progress, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Content; } diff --git a/src/redmine-net-api/RedmineManager.Obsolete.cs b/src/redmine-net-api/RedmineManager.Obsolete.cs deleted file mode 100644 index 950ecafb..00000000 --- a/src/redmine-net-api/RedmineManager.Obsolete.cs +++ /dev/null @@ -1,617 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Net; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Net.WebClient; -using Redmine.Net.Api.Serialization; -using Redmine.Net.Api.Types; - -namespace Redmine.Net.Api -{ - /// - /// The main class to access Redmine API. - /// - public partial class RedmineManager - { - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use RedmineConstants.DEFAULT_PAGE_SIZE")] - public const int DEFAULT_PAGE_SIZE_VALUE = 25; - - /// - /// Initializes a new instance of the class. - /// - /// The host. - /// The MIME format. - /// if set to true [verify server cert]. - /// The proxy. - /// Use this parameter to specify a SecurityProtocolType. - /// Note: it is recommended to leave this parameter at its default value as this setting also affects the calling application process. - /// http or https. Default is https. - /// The webclient timeout. Default is 100 seconds. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use RedmineManager(RedmineManagerOptionsBuilder")] - public RedmineManager(string host, MimeFormat mimeFormat = MimeFormat.Xml, bool verifyServerCert = true, - IWebProxy proxy = null, SecurityProtocolType securityProtocolType = default, string scheme = "https", TimeSpan? timeout = null) - :this(new RedmineManagerOptionsBuilder() - .WithHost(host) - .WithSerializationType(mimeFormat) - .WithVerifyServerCert(verifyServerCert) - .WithWebClientOptions(new RedmineWebClientOptions() - { - Proxy = proxy, - Scheme = scheme, - Timeout = timeout, - SecurityProtocolType = securityProtocolType - }) - ) { } - - /// - /// Initializes a new instance of the class using your API key for authentication. - /// - /// - /// To enable the API-style authentication, you have to check Enable REST API in Administration -&gt; Settings -&gt; Authentication. - /// You can find your API key on your account page ( /my/account ) when logged in, on the right-hand pane of the default layout. - /// - /// The host. - /// The API key. - /// The MIME format. - /// if set to true [verify server cert]. - /// The proxy. - /// Use this parameter to specify a SecurityProtocolType. - /// Note: it is recommended to leave this parameter at its default value as this setting also affects the calling application process. - /// - /// The webclient timeout. Default is 100 seconds. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use RedmineManager(RedmineManagerOptionsBuilder")] - public RedmineManager(string host, string apiKey, MimeFormat mimeFormat = MimeFormat.Xml, - bool verifyServerCert = true, IWebProxy proxy = null, - SecurityProtocolType securityProtocolType = default, string scheme = "https", TimeSpan? timeout = null) - : this(new RedmineManagerOptionsBuilder() - .WithHost(host) - .WithApiKeyAuthentication(apiKey) - .WithSerializationType(mimeFormat) - .WithVerifyServerCert(verifyServerCert) - .WithWebClientOptions(new RedmineWebClientOptions() - { - Proxy = proxy, - Scheme = scheme, - Timeout = timeout, - SecurityProtocolType = securityProtocolType - })){} - - /// - /// Initializes a new instance of the class using your login and password for authentication. - /// - /// The host. - /// The login. - /// The password. - /// The MIME format. - /// if set to true [verify server cert]. - /// The proxy. - /// Use this parameter to specify a SecurityProtocolType. Note: it is recommended to leave this parameter at its default value as this setting also affects the calling application process. - /// - /// The webclient timeout. Default is 100 seconds. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " Use RedmineManager(RedmineManagerOptionsBuilder")] - public RedmineManager(string host, string login, string password, MimeFormat mimeFormat = MimeFormat.Xml, - bool verifyServerCert = true, IWebProxy proxy = null, - SecurityProtocolType securityProtocolType = default, string scheme = "https", TimeSpan? timeout = null) - : this(new RedmineManagerOptionsBuilder() - .WithHost(host) - .WithBasicAuthentication(login, password) - .WithSerializationType(mimeFormat) - .WithVerifyServerCert(verifyServerCert) - .WithWebClientOptions(new RedmineWebClientOptions() - { - Proxy = proxy, - Scheme = scheme, - Timeout = timeout, - SecurityProtocolType = securityProtocolType - })) {} - - - /// - /// Gets the suffixes. - /// - /// - /// The suffixes. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + " It returns null.")] - public static Dictionary Suffixes => null; - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public string Format { get; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public string Scheme { get; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public TimeSpan? Timeout { get; } - - /// - /// Gets the host. - /// - /// - /// The host. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public string Host { get; } - - /// - /// The ApiKey used to authenticate. - /// - /// - /// The API key. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public string ApiKey { get; } - - /// - /// Gets the MIME format. - /// - /// - /// The MIME format. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public MimeFormat MimeFormat { get; } - - /// - /// Gets the proxy. - /// - /// - /// The proxy. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public IWebProxy Proxy { get; } - - /// - /// Gets the type of the security protocol. - /// - /// - /// The type of the security protocol. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public SecurityProtocolType SecurityProtocolType { get; } - - - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public int PageSize { get; set; } - - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public string ImpersonateUser { get; set; } - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT )] - public static readonly Dictionary TypesWithOffset = new Dictionary{ - {typeof(Issue), true}, - {typeof(Project), true}, - {typeof(User), true}, - {typeof(News), true}, - {typeof(Query), true}, - {typeof(TimeEntry), true}, - {typeof(ProjectMembership), true}, - {typeof(Search), true} - }; - - /// - /// Returns the user whose credentials are used to access the API. - /// - /// The accepted parameters are: memberships and groups (added in 2.1). - /// - /// - /// An error occurred during deserialization. The original exception is available - /// using the System.Exception.InnerException property. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use GetCurrentUser extension instead")] - public User GetCurrentUser(NameValueCollection parameters = null) - { - return this.GetCurrentUser(RedmineManagerExtensions.CreateRequestOptions(parameters)); - } - - /// - /// - /// - /// Returns the my account details. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use GetMyAccount extension instead")] - public MyAccount GetMyAccount() - { - return RedmineManagerExtensions.GetMyAccount(this); - } - - /// - /// Adds the watcher to issue. - /// - /// The issue identifier. - /// The user identifier. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use AddWatcherToIssue extension instead")] - public void AddWatcherToIssue(int issueId, int userId) - { - RedmineManagerExtensions.AddWatcherToIssue(this, issueId, userId); - } - - /// - /// Removes the watcher from issue. - /// - /// The issue identifier. - /// The user identifier. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use RemoveWatcherFromIssue extension instead")] - public void RemoveWatcherFromIssue(int issueId, int userId) - { - RedmineManagerExtensions.RemoveWatcherFromIssue(this, issueId, userId); - } - - /// - /// Adds an existing user to a group. - /// - /// The group id. - /// The user id. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use AddUserToGroup extension instead")] - public void AddUserToGroup(int groupId, int userId) - { - RedmineManagerExtensions.AddUserToGroup(this, groupId, userId); - } - - /// - /// Removes an user from a group. - /// - /// The group id. - /// The user id. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use RemoveUserFromGroup extension instead")] - public void RemoveUserFromGroup(int groupId, int userId) - { - RedmineManagerExtensions.RemoveUserFromGroup(this, groupId, userId); - } - - /// - /// Creates or updates a wiki page. - /// - /// The project id or identifier. - /// The wiki page name. - /// The wiki page to create or update. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use UpdateWikiPage extension instead")] - public void UpdateWikiPage(string projectId, string pageName, WikiPage wikiPage) - { - RedmineManagerExtensions.UpdateWikiPage(this, projectId, pageName, wikiPage); - } - - /// - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use CreateWikiPage extension instead")] - public WikiPage CreateWikiPage(string projectId, string pageName, WikiPage wikiPage) - { - return RedmineManagerExtensions.CreateWikiPage(this, projectId, pageName, wikiPage); - } - - /// - /// Gets the wiki page. - /// - /// The project identifier. - /// The parameters. - /// Name of the page. - /// The version. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use GetWikiPage extension instead")] - public WikiPage GetWikiPage(string projectId, NameValueCollection parameters, string pageName, uint version = 0) - { - return this.GetWikiPage(projectId, pageName, RedmineManagerExtensions.CreateRequestOptions(parameters), version); - } - - /// - /// Returns the list of all pages in a project wiki. - /// - /// The project id or identifier. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use GetAllWikiPages extension instead")] - public List GetAllWikiPages(string projectId) - { - return RedmineManagerExtensions.GetAllWikiPages(this, projectId); - } - - /// - /// Deletes a wiki page, its attachments and its history. If the deleted page is a parent page, its child pages are not - /// deleted but changed as root pages. - /// - /// The project id or identifier. - /// The wiki page name. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use DeleteWikiPage extension instead")] - public void DeleteWikiPage(string projectId, string pageName) - { - RedmineManagerExtensions.DeleteWikiPage(this, projectId, pageName); - } - - /// - /// Updates the attachment. - /// - /// The issue identifier. - /// The attachment. - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use UpdateAttachment extension instead")] - public void UpdateAttachment(int issueId, Attachment attachment) - { - this.UpdateIssueAttachment(issueId, attachment); - } - - /// - /// - /// - /// query strings. enable to specify multiple values separated by a space " ". - /// number of results in response. - /// skip this number of results in response - /// Optional filters. - /// - /// Returns the search results by the specified condition parameters. - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use Search extension instead")] - public PagedResults Search(string q, int limit = DEFAULT_PAGE_SIZE_VALUE, int offset = 0, SearchFilterBuilder searchFilter = null) - { - return RedmineManagerExtensions.Search(this, q, limit, offset, searchFilter); - } - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public int Count(params string[] include) where T : class, new() - { - var parameters = NameValueCollectionExtensions.AddParamsIfExist(null, include); - - return Count(parameters); - } - - /// - /// - /// - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public int Count(NameValueCollection parameters) where T : class, new() - { - return Count(parameters != null ? new RequestOptions { QueryString = parameters } : null); - } - - /// - /// Gets the redmine object based on id. - /// - /// The type of objects to retrieve. - /// The id of the object. - /// Optional filters and/or optional fetched data. - /// - /// Returns the object of type T. - /// - /// - /// - /// string issueId = "927"; - /// NameValueCollection parameters = null; - /// Issue issue = redmineManager.GetObject<Issue>(issueId, parameters); - /// - /// - [Obsolete($"{RedmineConstants.OBSOLETE_TEXT} Use Get instead")] - public T GetObject(string id, NameValueCollection parameters) where T : class, new() - { - return Get(id, parameters != null ? new RequestOptions { QueryString = parameters } : null); - } - - /// - /// Returns the complete list of objects. - /// - /// - /// Optional fetched data. - /// - /// Optional fetched data: - /// Project: trackers, issue_categories, enabled_modules (since Redmine 2.6.0) - /// Issue: children, attachments, relations, changesets, journals, watchers (since Redmine 2.3.0) - /// Users: memberships, groups (since Redmine 2.1) - /// Groups: users, memberships - /// - /// Returns the complete list of objects. - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public List GetObjects(params string[] include) where T : class, new() - { - var parameters = NameValueCollectionExtensions.AddParamsIfExist(null, include); - - return GetObjects(parameters); - } - - /// - /// Returns the complete list of objects. - /// - /// - /// The page size. - /// The offset. - /// Optional fetched data. - /// - /// Optional fetched data: - /// Project: trackers, issue_categories, enabled_modules (since 2.6.0) - /// Issue: children, attachments, relations, changesets, journals, watchers - Since 2.3.0 - /// Users: memberships, groups (added in 2.1) - /// Groups: users, memberships - /// - /// Returns the complete list of objects. - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public List GetObjects(int limit, int offset, params string[] include) where T : class, new() - { - var parameters = NameValueCollectionExtensions - .AddParamsIfExist(null, include) - .AddPagingParameters(limit, offset); - - return GetObjects(parameters); - } - - /// - /// Returns the complete list of objects. - /// - /// The type of objects to retrieve. - /// Optional filters and/or optional fetched data. - /// - /// Returns a complete list of objects. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public List GetObjects(NameValueCollection parameters = null) where T : class, new() - { - return Get(parameters != null ? new RequestOptions { QueryString = parameters } : null); - } - - /// - /// Gets the paginated objects. - /// - /// - /// The parameters. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public PagedResults GetPaginatedObjects(NameValueCollection parameters) where T : class, new() - { - return GetPaginated(parameters != null ? new RequestOptions { QueryString = parameters } : null); - } - - /// - /// Creates a new Redmine object. - /// - /// The type of object to create. - /// The object to create. - /// - /// - /// - /// When trying to create an object with invalid or missing attribute parameters, you will get a 422 Unprocessable - /// Entity response. That means that the object could not be created. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public T CreateObject(T entity) where T : class, new() - { - return Create(entity); - } - - /// - /// Creates a new Redmine object. - /// - /// The type of object to create. - /// The object to create. - /// The owner identifier. - /// - /// - /// - /// When trying to create an object with invalid or missing attribute parameters, you will get a 422 Unprocessable - /// Entity response. That means that the object could not be created. - /// - /// - /// - /// var project = new Project(); - /// project.Name = "test"; - /// project.Identifier = "the project identifier"; - /// project.Description = "the project description"; - /// redmineManager.CreateObject(project); - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public T CreateObject(T entity, string ownerId) where T : class, new() - { - return Create(entity, ownerId); - } - - /// - /// Updates a Redmine object. - /// - /// The type of object to be updated. - /// The id of the object to be updated. - /// The object to be updated. - /// The project identifier. - /// - /// - /// When trying to update an object with invalid or missing attribute parameters, you will get a - /// 422(RedmineException) Unprocessable Entity response. That means that the object could not be updated. - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public void UpdateObject(string id, T entity, string projectId = null) where T : class, new() - { - Update(id, entity, projectId); - } - - /// - /// Deletes the Redmine object. - /// - /// The type of objects to delete. - /// The id of the object to delete - /// The parameters - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public void DeleteObject(string id, NameValueCollection parameters = null) where T : class, new() - { - Delete(id, parameters != null ? new RequestOptions { QueryString = parameters } : null); - } - - /// - /// Creates the Redmine web client. - /// - /// The parameters. - /// if set to true [upload file]. - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "If a custom webClient is needed, use Func from RedmineManagerSettings instead")] - public virtual RedmineWebClient CreateWebClient(NameValueCollection parameters, bool uploadFile = false) - { - throw new NotImplementedException(); - } - - /// - /// This is to take care of SSL certification validation which are not issued by Trusted Root CA. - /// - /// The sender. - /// The cert. - /// The chain. - /// The error. - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use RedmineManagerOptions.ClientOptions.ServerCertificateValidationCallback instead")] - public virtual bool RemoteCertValidate(object sender, X509Certificate cert, X509Chain chain, SslPolicyErrors sslPolicyErrors) - { - const SslPolicyErrors IGNORED_ERRORS = SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNameMismatch; - - return (sslPolicyErrors & ~IGNORED_ERRORS) == SslPolicyErrors.None; - - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/RedmineManager.cs b/src/redmine-net-api/RedmineManager.cs index 6e10e7b4..ca062ef8 100644 --- a/src/redmine-net-api/RedmineManager.cs +++ b/src/redmine-net-api/RedmineManager.cs @@ -17,13 +17,19 @@ limitations under the License. using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.Globalization; using System.Net; -using Redmine.Net.Api.Authentication; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Clients.WebClient; +using Redmine.Net.Api.Http.Extensions; using Redmine.Net.Api.Internals; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Net.WebClient; +using Redmine.Net.Api.Logging; +#if NET40_OR_GREATER || NET +using Redmine.Net.Api.Http.Clients.HttpClient; +#endif +using Redmine.Net.Api.Net.Internal; +using Redmine.Net.Api.Options; using Redmine.Net.Api.Serialization; using Redmine.Net.Api.Types; @@ -39,6 +45,7 @@ public partial class RedmineManager : IRedmineManager internal IRedmineSerializer Serializer { get; } internal RedmineApiUrls RedmineApiUrls { get; } internal IRedmineApiClient ApiClient { get; } + internal IRedmineLogger Logger { get; } /// /// @@ -50,56 +57,97 @@ public RedmineManager(RedmineManagerOptionsBuilder optionsBuilder) ArgumentNullThrowHelper.ThrowIfNull(optionsBuilder, nameof(optionsBuilder)); _redmineManagerOptions = optionsBuilder.Build(); - #if NET45_OR_GREATER - if (_redmineManagerOptions.VerifyServerCert) + + Logger = _redmineManagerOptions.Logger; + Serializer = _redmineManagerOptions.Serializer; + RedmineApiUrls = new RedmineApiUrls(_redmineManagerOptions.Serializer.Format); + + ApiClient = +#if NET40_OR_GREATER || NET + _redmineManagerOptions.ApiClientOptions switch + { + RedmineWebClientOptions => CreateWebClient(_redmineManagerOptions), + RedmineHttpClientOptions => CreateHttpClient(_redmineManagerOptions), + }; +#else + CreateWebClient(_redmineManagerOptions); +#endif + } + + private static InternalRedmineApiWebClient CreateWebClient(RedmineManagerOptions options) + { + if (options.ClientFunc != null) { - _redmineManagerOptions.WebClientOptions.ServerCertificateValidationCallback = RemoteCertValidate; + return new InternalRedmineApiWebClient(options.ClientFunc, options); } - #endif - if (_redmineManagerOptions.WebClientOptions is RedmineWebClientOptions) + ApplyServiceManagerSettings(options.WebClientOptions); +#pragma warning disable SYSLIB0014 + options.WebClientOptions.SecurityProtocolType ??= ServicePointManager.SecurityProtocol; +#pragma warning restore SYSLIB0014 + + return new InternalRedmineApiWebClient(options); + } +#if NET40_OR_GREATER || NET + private InternalRedmineApiHttpClient CreateHttpClient(RedmineManagerOptions options) + { + return options.HttpClient != null + ? new InternalRedmineApiHttpClient(options.HttpClient, options) + : new InternalRedmineApiHttpClient(_redmineManagerOptions); + } +#endif + + private static void ApplyServiceManagerSettings(RedmineWebClientOptions options) + { + if (options == null) { - Proxy = _redmineManagerOptions.WebClientOptions.Proxy; - Timeout = _redmineManagerOptions.WebClientOptions.Timeout; - SecurityProtocolType = _redmineManagerOptions.WebClientOptions.SecurityProtocolType.GetValueOrDefault(); - #pragma warning disable SYSLIB0014 - _redmineManagerOptions.WebClientOptions.SecurityProtocolType ??= ServicePointManager.SecurityProtocol; - #pragma warning restore SYSLIB0014 + return; } - - if (_redmineManagerOptions.Authentication is RedmineApiKeyAuthentication) + + if (options.SecurityProtocolType.HasValue) { - ApiKey = _redmineManagerOptions.Authentication.Token; + ServicePointManager.SecurityProtocol = options.SecurityProtocolType.Value; } - - Serializer = _redmineManagerOptions.Serializer; - Host = _redmineManagerOptions.BaseAddress.ToString(); - PageSize = _redmineManagerOptions.PageSize; - Scheme = _redmineManagerOptions.BaseAddress.Scheme; - Format = Serializer.Format; - MimeFormat = RedmineConstants.XML.Equals(Serializer.Format, StringComparison.Ordinal) - ? MimeFormat.Xml - : MimeFormat.Json; - - RedmineApiUrls = new RedmineApiUrls(Serializer.Format); - #if NET45_OR_GREATER || NETCOREAPP - if (_redmineManagerOptions.WebClientOptions is RedmineWebClientOptions) + + if (options.DefaultConnectionLimit.HasValue) { - ApiClient = _redmineManagerOptions.ClientFunc != null - ? new InternalRedmineApiWebClient(_redmineManagerOptions.ClientFunc, _redmineManagerOptions.Authentication, _redmineManagerOptions.Serializer) - : new InternalRedmineApiWebClient(_redmineManagerOptions); + ServicePointManager.DefaultConnectionLimit = options.DefaultConnectionLimit.Value; } - else + + if (options.DnsRefreshTimeout.HasValue) { - + ServicePointManager.DnsRefreshTimeout = options.DnsRefreshTimeout.Value; } - #else - ApiClient = _redmineManagerOptions.ClientFunc != null - ? new InternalRedmineApiWebClient(_redmineManagerOptions.ClientFunc, _redmineManagerOptions.Authentication, _redmineManagerOptions.Serializer) - : new InternalRedmineApiWebClient(_redmineManagerOptions); - #endif + + if (options.EnableDnsRoundRobin.HasValue) + { + ServicePointManager.EnableDnsRoundRobin = options.EnableDnsRoundRobin.Value; + } + + if (options.MaxServicePoints.HasValue) + { + ServicePointManager.MaxServicePoints = options.MaxServicePoints.Value; + } + + if (options.MaxServicePointIdleTime.HasValue) + { + ServicePointManager.MaxServicePointIdleTime = options.MaxServicePointIdleTime.Value; + } + +#if(NET46_OR_GREATER || NET) + if (options.ReusePort.HasValue) + { + ServicePointManager.ReusePort = options.ReusePort.Value; + } +#endif + #if NEFRAMEWORK + if (options.CheckCertificateRevocationList) + { + ServicePointManager.CheckCertificateRevocationList = true; + } +#endif } - + /// public int Count(RequestOptions requestOptions = null) where T : class, new() @@ -108,10 +156,7 @@ public int Count(RequestOptions requestOptions = null) const int PAGE_SIZE = 1; const int OFFSET = 0; - if (requestOptions == null) - { - requestOptions = new RequestOptions(); - } + requestOptions ??= new RequestOptions(); requestOptions.QueryString = requestOptions.QueryString.AddPagingParameters(PAGE_SIZE, OFFSET); @@ -200,9 +245,9 @@ public Upload UploadFile(byte[] data, string fileName = null) } /// - public byte[] DownloadFile(string address) + public byte[] DownloadFile(string address, IProgress progress = null) { - var response = ApiClient.Download(address); + var response = ApiClient.Download(address, progress: progress); return response.Content; } @@ -236,7 +281,7 @@ internal List GetInternal(string uri, RequestOptions requestOptions = null if (pageSize == default) { pageSize = _redmineManagerOptions.PageSize > 0 ? _redmineManagerOptions.PageSize : RedmineConstants.DEFAULT_PAGE_SIZE_VALUE; - requestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToString(CultureInfo.InvariantCulture)); + requestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToInvariantString()); } var hasOffset = TypesWithOffset.ContainsKey(typeof(T)); @@ -245,7 +290,7 @@ internal List GetInternal(string uri, RequestOptions requestOptions = null int totalCount; do { - requestOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture)); + requestOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToInvariantString()); var tempResult = GetPaginatedInternal(uri, requestOptions); @@ -295,5 +340,16 @@ internal PagedResults GetPaginatedInternal(string uri = null, RequestOptio return response.DeserializeToPagedResults(Serializer); } + + internal static readonly Dictionary TypesWithOffset = new Dictionary{ + {typeof(Issue), true}, + {typeof(Project), true}, + {typeof(User), true}, + {typeof(News), true}, + {typeof(Query), true}, + {typeof(TimeEntry), true}, + {typeof(ProjectMembership), true}, + {typeof(Search), true} + }; } } \ No newline at end of file diff --git a/src/redmine-net-api/RedmineManagerOptionsBuilder.cs b/src/redmine-net-api/RedmineManagerOptionsBuilder.cs deleted file mode 100644 index b54db5b3..00000000 --- a/src/redmine-net-api/RedmineManagerOptionsBuilder.cs +++ /dev/null @@ -1,422 +0,0 @@ -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Net; -using Redmine.Net.Api.Authentication; -using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Net.WebClient; -using Redmine.Net.Api.Serialization; - -namespace Redmine.Net.Api -{ - /// - /// - /// - public sealed class RedmineManagerOptionsBuilder - { - private enum ClientType - { - WebClient, - HttpClient, - } - private ClientType _clientType = ClientType.WebClient; - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithPageSize(int pageSize) - { - this.PageSize = pageSize; - return this; - } - - /// - /// - /// - public int PageSize { get; private set; } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithHost(string baseAddress) - { - this.Host = baseAddress; - return this; - } - - /// - /// - /// - public string Host { get; private set; } - - /// - /// - /// - /// - /// - internal RedmineManagerOptionsBuilder WithSerializationType(MimeFormat mimeFormat) - { - this.SerializationType = mimeFormat == MimeFormat.Xml ? SerializationType.Xml : SerializationType.Json; - return this; - } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithSerializationType(SerializationType serializationType) - { - this.SerializationType = serializationType; - return this; - } - - /// - /// Gets the current serialization type - /// - public SerializationType SerializationType { get; private set; } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithApiKeyAuthentication(string apiKey) - { - this.Authentication = new RedmineApiKeyAuthentication(apiKey); - return this; - } - - /// - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithBasicAuthentication(string login, string password) - { - this.Authentication = new RedmineBasicAuthentication(login, password); - return this; - } - - /// - /// - /// - public IRedmineAuthentication Authentication { get; private set; } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithWebClient(Func clientFunc) - { - _clientType = ClientType.WebClient; - this.ClientFunc = clientFunc; - return this; - } - - /// - /// - /// - public Func ClientFunc { get; private set; } - - /// - /// - /// - /// - /// - [Obsolete("Use WithWebClientOptions(IRedmineWebClientOptions clientOptions) instead.")] - public RedmineManagerOptionsBuilder WithWebClientOptions(IRedmineApiClientOptions clientOptions) - { - return WithWebClientOptions((IRedmineWebClientOptions)clientOptions); - } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithWebClientOptions(IRedmineWebClientOptions clientOptions) - { - _clientType = ClientType.WebClient; - this.WebClientOptions = clientOptions; - return this; - } - - /// - /// - /// - [Obsolete("Use WebClientOptions instead.")] - public IRedmineApiClientOptions ClientOptions - { - get => WebClientOptions; - private set { } - } - - /// - /// - /// - public IRedmineWebClientOptions WebClientOptions { get; private set; } - - /// - /// - /// - /// - /// - public RedmineManagerOptionsBuilder WithVersion(Version version) - { - this.Version = version; - return this; - } - - /// - /// - /// - public Version Version { get; set; } - - internal RedmineManagerOptionsBuilder WithVerifyServerCert(bool verifyServerCert) - { - this.VerifyServerCert = verifyServerCert; - return this; - } - - /// - /// - /// - public bool VerifyServerCert { get; private set; } - - /// - /// - /// - /// - internal RedmineManagerOptions Build() - { - const string defaultUserAgent = "Redmine.Net.Api.Net"; - var defaultDecompressionFormat = - #if NETFRAMEWORK - DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.None; - #else - DecompressionMethods.All; - #endif - #if NET45_OR_GREATER || NETCOREAPP - WebClientOptions ??= _clientType switch - { - ClientType.WebClient => new RedmineWebClientOptions() - { - UserAgent = defaultUserAgent, - DecompressionFormat = defaultDecompressionFormat, - }, - _ => throw new ArgumentOutOfRangeException() - }; - #else - WebClientOptions ??= new RedmineWebClientOptions() - { - UserAgent = defaultUserAgent, - DecompressionFormat = defaultDecompressionFormat, - }; - #endif - var baseAddress = CreateRedmineUri(Host, WebClientOptions.Scheme); - - var options = new RedmineManagerOptions() - { - BaseAddress = baseAddress, - PageSize = PageSize > 0 ? PageSize : RedmineConstants.DEFAULT_PAGE_SIZE_VALUE, - VerifyServerCert = VerifyServerCert, - Serializer = RedmineSerializerFactory.CreateSerializer(SerializationType), - RedmineVersion = Version, - Authentication = Authentication ?? new RedmineNoAuthentication(), - WebClientOptions = WebClientOptions - }; - - return options; - } - - private static readonly char[] DotCharArray = ['.']; - - internal static void EnsureDomainNameIsValid(string domainName) - { - if (domainName.IsNullOrWhiteSpace()) - { - throw new RedmineException("Domain name cannot be null or empty."); - } - - if (domainName.Length > 255) - { - throw new RedmineException("Domain name cannot be longer than 255 characters."); - } - - var labels = domainName.Split(DotCharArray); - if (labels.Length == 1) - { - throw new RedmineException("Domain name is not valid."); - } - foreach (var label in labels) - { - if (label.IsNullOrWhiteSpace() || label.Length > 63) - { - throw new RedmineException("Domain name must be between 1 and 63 characters."); - } - - if (!char.IsLetterOrDigit(label[0]) || !char.IsLetterOrDigit(label[label.Length - 1])) - { - throw new RedmineException("Domain name starts or ends with a hyphen."); - } - - for (var i = 0; i < label.Length; i++) - { - var c = label[i]; - - if (!char.IsLetterOrDigit(c) && c != '-') - { - throw new RedmineException("Domain name contains an invalid character."); - } - - if (c != '-') - { - continue; - } - - if (i + 1 < label.Length && (c ^ label[i+1]) == 0) - { - throw new RedmineException("Domain name contains consecutive hyphens."); - } - } - } - } - - internal static Uri CreateRedmineUri(string host, string scheme = null) - { - if (host.IsNullOrWhiteSpace() || host.Equals("string.Empty", StringComparison.OrdinalIgnoreCase)) - { - throw new RedmineException("The host is null or empty."); - } - - if (!Uri.TryCreate(host, UriKind.Absolute, out var uri)) - { - host = host.TrimEnd('/', '\\'); - EnsureDomainNameIsValid(host); - - if (!host.StartsWith(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || !host.StartsWith(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - host = $"{scheme ?? Uri.UriSchemeHttps}://{host}"; - - if (!Uri.TryCreate(host, UriKind.Absolute, out uri)) - { - throw new RedmineException("The host is not valid."); - } - } - } - - if (!uri.IsWellFormedOriginalString()) - { - throw new RedmineException("The host is not well-formed."); - } - - scheme ??= Uri.UriSchemeHttps; - var hasScheme = false; - if (!uri.Scheme.IsNullOrWhiteSpace()) - { - if (uri.Host.IsNullOrWhiteSpace() && uri.IsAbsoluteUri && !uri.IsFile) - { - if (uri.Scheme.Equals("localhost", StringComparison.OrdinalIgnoreCase)) - { - int port = 0; - var portAsString = uri.AbsolutePath.RemoveTrailingSlash(); - if (!portAsString.IsNullOrWhiteSpace()) - { - int.TryParse(portAsString, out port); - } - - var ub = new UriBuilder(scheme, "localhost", port); - return ub.Uri; - } - } - else - { - if (!IsSchemaHttpOrHttps(uri.Scheme)) - { - throw new RedmineException("Invalid host scheme. Only HTTP and HTTPS are supported."); - } - - hasScheme = true; - } - } - else - { - if (!IsSchemaHttpOrHttps(scheme)) - { - throw new RedmineException("Invalid host scheme. Only HTTP and HTTPS are supported."); - } - } - - var uriBuilder = new UriBuilder(); - - if (uri.HostNameType == UriHostNameType.IPv6) - { - uriBuilder.Scheme = (hasScheme ? uri.Scheme : scheme ?? Uri.UriSchemeHttps); - uriBuilder.Host = uri.Host; - } - else - { - if (uri.Authority.IsNullOrWhiteSpace()) - { - if (uri.Port == -1) - { - if (int.TryParse(uri.LocalPath, out var port)) - { - uriBuilder.Port = port; - } - } - - uriBuilder.Scheme = scheme ?? Uri.UriSchemeHttps; - uriBuilder.Host = uri.Scheme; - } - else - { - uriBuilder.Scheme = uri.Scheme; - uriBuilder.Port = int.TryParse(uri.LocalPath, out var port) ? port : uri.Port; - uriBuilder.Host = uri.Host; - if (!uri.LocalPath.IsNullOrWhiteSpace() && !uri.LocalPath.Contains(".")) - { - uriBuilder.Path = uri.LocalPath; - } - } - } - - try - { - return uriBuilder.Uri; - } - catch (Exception ex) - { - throw new RedmineException(ex.Message); - } - } - - private static bool IsSchemaHttpOrHttps(string scheme) - { - return scheme == Uri.UriSchemeHttp || scheme == Uri.UriSchemeHttps; - } - } -} \ No newline at end of file diff --git a/src/redmine-net-api/SearchFilterBuilder.cs b/src/redmine-net-api/SearchFilterBuilder.cs index 856fb1a7..9b2b8c9c 100644 --- a/src/redmine-net-api/SearchFilterBuilder.cs +++ b/src/redmine-net-api/SearchFilterBuilder.cs @@ -17,6 +17,7 @@ limitations under the License. using System; using System.Collections.Specialized; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http.Extensions; namespace Redmine.Net.Api { diff --git a/src/redmine-net-api/Serialization/IRedmineSerializer.cs b/src/redmine-net-api/Serialization/IRedmineSerializer.cs index e6e064f4..eef94866 100644 --- a/src/redmine-net-api/Serialization/IRedmineSerializer.cs +++ b/src/redmine-net-api/Serialization/IRedmineSerializer.cs @@ -14,6 +14,8 @@ You may obtain a copy of the License at limitations under the License. */ +using Redmine.Net.Api.Common; + namespace Redmine.Net.Api.Serialization { /// @@ -25,6 +27,11 @@ internal interface IRedmineSerializer /// Gets the application format this serializer supports (e.g. "json", "xml"). /// string Format { get; } + + /// + /// + /// + string ContentType { get; } /// /// Serializes the specified object into a string. diff --git a/src/redmine-net-api/Serialization/Json/Extensions/JsonReaderExtensions.cs b/src/redmine-net-api/Serialization/Json/Extensions/JsonReaderExtensions.cs index 7d490315..8dc4e76e 100644 --- a/src/redmine-net-api/Serialization/Json/Extensions/JsonReaderExtensions.cs +++ b/src/redmine-net-api/Serialization/Json/Extensions/JsonReaderExtensions.cs @@ -18,9 +18,8 @@ limitations under the License. using System.Collections.Generic; using Newtonsoft.Json; using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Serialization; -namespace Redmine.Net.Api.Extensions +namespace Redmine.Net.Api.Serialization.Json.Extensions { /// /// diff --git a/src/redmine-net-api/Serialization/Json/Extensions/JsonWriterExtensions.cs b/src/redmine-net-api/Serialization/Json/Extensions/JsonWriterExtensions.cs index c921d706..f3df1629 100644 --- a/src/redmine-net-api/Serialization/Json/Extensions/JsonWriterExtensions.cs +++ b/src/redmine-net-api/Serialization/Json/Extensions/JsonWriterExtensions.cs @@ -20,10 +20,11 @@ limitations under the License. using System.Globalization; using System.Text; using Newtonsoft.Json; -using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Types; -namespace Redmine.Net.Api.Extensions +namespace Redmine.Net.Api.Serialization.Json.Extensions { /// /// @@ -72,7 +73,7 @@ public static void WriteIfNotDefaultOrNull(this JsonWriter writer, string ele /// The property name. public static void WriteBoolean(this JsonWriter writer, string elementName, bool value) { - writer.WriteProperty(elementName, value.ToString().ToLowerInv()); + writer.WriteProperty(elementName, value.ToInvariantString()); } /// @@ -84,7 +85,7 @@ public static void WriteBoolean(this JsonWriter writer, string elementName, bool /// public static void WriteIdOrEmpty(this JsonWriter jsonWriter, string tag, IdentifiableName ident, string emptyValue = null) { - jsonWriter.WriteProperty(tag, ident != null ? ident.Id.ToString(CultureInfo.InvariantCulture) : emptyValue); + jsonWriter.WriteProperty(tag, ident != null ? ident.Id.ToInvariantString() : emptyValue); } /// @@ -212,7 +213,7 @@ public static void WriteArrayIds(this JsonWriter jsonWriter, string tag, IEnumer foreach (var identifiableName in collection) { - sb.Append(identifiableName.Id.ToString(CultureInfo.InvariantCulture)).Append(','); + sb.Append(identifiableName.Id.ToInvariantString()).Append(','); } if (sb.Length > 1) diff --git a/src/redmine-net-api/Serialization/Json/IJsonSerializable.cs b/src/redmine-net-api/Serialization/Json/IJsonSerializable.cs index af82ab73..bf6b25b7 100644 --- a/src/redmine-net-api/Serialization/Json/IJsonSerializable.cs +++ b/src/redmine-net-api/Serialization/Json/IJsonSerializable.cs @@ -16,7 +16,7 @@ limitations under the License. using Newtonsoft.Json; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Json { /// /// diff --git a/src/redmine-net-api/Serialization/Json/JsonObject.cs b/src/redmine-net-api/Serialization/Json/JsonObject.cs index 612b2a10..38e70807 100644 --- a/src/redmine-net-api/Serialization/Json/JsonObject.cs +++ b/src/redmine-net-api/Serialization/Json/JsonObject.cs @@ -18,7 +18,7 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Json { /// /// diff --git a/src/redmine-net-api/Serialization/Json/JsonRedmineSerializer.cs b/src/redmine-net-api/Serialization/Json/JsonRedmineSerializer.cs index 41f66958..cd078537 100644 --- a/src/redmine-net-api/Serialization/Json/JsonRedmineSerializer.cs +++ b/src/redmine-net-api/Serialization/Json/JsonRedmineSerializer.cs @@ -19,130 +19,118 @@ limitations under the License. using System.IO; using System.Text; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Exceptions; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Serialization.Json.Extensions; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Json { internal sealed class JsonRedmineSerializer : IRedmineSerializer { - public T Deserialize(string jsonResponse) where T : new() + private static void EnsureJsonSerializable() { - if (jsonResponse.IsNullOrWhiteSpace()) - { - throw new ArgumentNullException(nameof(jsonResponse), $"Could not deserialize null or empty input for type '{typeof(T).Name}'."); - } - - var isJsonSerializable = typeof(IJsonSerializable).IsAssignableFrom(typeof(T)); - - if (!isJsonSerializable) + if (!typeof(IJsonSerializable).IsAssignableFrom(typeof(T))) { throw new RedmineException($"Entity of type '{typeof(T)}' should implement IJsonSerializable."); } + } - using (var stringReader = new StringReader(jsonResponse)) - { - using (var jsonReader = new JsonTextReader(stringReader)) - { - var obj = Activator.CreateInstance(); + public T Deserialize(string jsonResponse) where T : new() + { + SerializationHelper.EnsureDeserializationInputIsNotNullOrWhiteSpace(jsonResponse, nameof(jsonResponse), typeof(T)); - if (jsonReader.Read()) - { - if (jsonReader.Read()) - { - ((IJsonSerializable)obj).ReadJson(jsonReader); - } - } + EnsureJsonSerializable(); - return obj; - } + using var stringReader = new StringReader(jsonResponse); + using var jsonReader = new JsonTextReader(stringReader); + var obj = Activator.CreateInstance(); + + if (jsonReader.Read() && jsonReader.Read()) + { + ((IJsonSerializable)obj).ReadJson(jsonReader); } + + return obj; } public PagedResults DeserializeToPagedResults(string jsonResponse) where T : class, new() { - if (jsonResponse.IsNullOrWhiteSpace()) - { - throw new ArgumentNullException(nameof(jsonResponse), $"Could not deserialize null or empty input for type '{typeof(T).Name}'."); - } + SerializationHelper.EnsureDeserializationInputIsNotNullOrWhiteSpace(jsonResponse, nameof(jsonResponse), typeof(T)); - using (var sr = new StringReader(jsonResponse)) + using var sr = new StringReader(jsonResponse); + using var reader = new JsonTextReader(sr); + var total = 0; + var offset = 0; + var limit = 0; + List list = null; + + while (reader.Read()) { - using (var reader = new JsonTextReader(sr)) + if (reader.TokenType != JsonToken.PropertyName) + { + continue; + } + + switch (reader.Value) { - var total = 0; - var offset = 0; - var limit = 0; - List list = null; - - while (reader.Read()) - { - if (reader.TokenType != JsonToken.PropertyName) continue; - - switch (reader.Value) - { - case RedmineKeys.TOTAL_COUNT: - total = reader.ReadAsInt32().GetValueOrDefault(); - break; - case RedmineKeys.OFFSET: - offset = reader.ReadAsInt32().GetValueOrDefault(); - break; - case RedmineKeys.LIMIT: - limit = reader.ReadAsInt32().GetValueOrDefault(); - break; - default: - list = reader.ReadAsCollection(); - break; - } - } - - return new PagedResults(list, total, offset, limit); + case RedmineKeys.TOTAL_COUNT: + total = reader.ReadAsInt32().GetValueOrDefault(); + break; + case RedmineKeys.OFFSET: + offset = reader.ReadAsInt32().GetValueOrDefault(); + break; + case RedmineKeys.LIMIT: + limit = reader.ReadAsInt32().GetValueOrDefault(); + break; + default: + list = reader.ReadAsCollection(); + break; } } + + return new PagedResults(list, total, offset, limit); } #pragma warning disable CA1822 public int Count(string jsonResponse) where T : class, new() { - if (jsonResponse.IsNullOrWhiteSpace()) - { - throw new ArgumentNullException(nameof(jsonResponse), $"Could not deserialize null or empty input for type '{typeof(T).Name}'."); - } + SerializationHelper.EnsureDeserializationInputIsNotNullOrWhiteSpace(jsonResponse, nameof(jsonResponse), typeof(T)); + + using var sr = new StringReader(jsonResponse); + using var reader = new JsonTextReader(sr); + var total = 0; - using (var sr = new StringReader(jsonResponse)) + while (reader.Read()) { - using (var reader = new JsonTextReader(sr)) + if (reader.TokenType != JsonToken.PropertyName) { - var total = 0; - - while (reader.Read()) - { - if (reader.TokenType != JsonToken.PropertyName) - { - continue; - } - - if (reader.Value is RedmineKeys.TOTAL_COUNT) - { - total = reader.ReadAsInt32().GetValueOrDefault(); - return total; - } - } + continue; + } + if (reader.Value is RedmineKeys.TOTAL_COUNT) + { + total = reader.ReadAsInt32().GetValueOrDefault(); return total; } } + + return total; } #pragma warning restore CA1822 - public string Format { get; } = "json"; + public string Format { get; } = RedmineConstants.JSON; + + public string ContentType { get; } = RedmineConstants.CONTENT_TYPE_APPLICATION_JSON; public string Serialize(T entity) where T : class { - if (entity == default(T)) + if (entity == null) { - throw new ArgumentNullException(nameof(entity), $"Could not serialize null of type {typeof(T).Name}"); + throw new RedmineSerializationException($"Could not serialize null of type {typeof(T).Name}", nameof(entity)); } + + EnsureJsonSerializable(); if (entity is not IJsonSerializable jsonSerializable) { @@ -151,22 +139,18 @@ public string Serialize(T entity) where T : class var stringBuilder = new StringBuilder(); - using (var sw = new StringWriter(stringBuilder)) - { - using (var writer = new JsonTextWriter(sw)) - { - writer.Formatting = Formatting.Indented; - writer.DateFormatHandling = DateFormatHandling.IsoDateFormat; + using var sw = new StringWriter(stringBuilder); + using var writer = new JsonTextWriter(sw); + //writer.Formatting = Formatting.Indented; + writer.DateFormatHandling = DateFormatHandling.IsoDateFormat; - jsonSerializable.WriteJson(writer); + jsonSerializable.WriteJson(writer); - var json = stringBuilder.ToString(); + var json = stringBuilder.ToString(); - stringBuilder.Length = 0; + stringBuilder.Length = 0; - return json; - } - } + return json; } } } \ No newline at end of file diff --git a/src/redmine-net-api/Serialization/MimeFormatObsolete.cs b/src/redmine-net-api/Serialization/MimeFormatObsolete.cs deleted file mode 100755 index 1bad6a4f..00000000 --- a/src/redmine-net-api/Serialization/MimeFormatObsolete.cs +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2011 - 2025 Adrian Popescu - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -using System; - -namespace Redmine.Net.Api -{ - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT + "Use SerializationType instead")] - public enum MimeFormat - { - /// - /// - Xml, - /// - /// The json - /// - Json - } -} \ No newline at end of file diff --git a/src/redmine-net-api/Serialization/RedmineSerializerFactory.cs b/src/redmine-net-api/Serialization/RedmineSerializerFactory.cs index a315ad44..6c71326e 100644 --- a/src/redmine-net-api/Serialization/RedmineSerializerFactory.cs +++ b/src/redmine-net-api/Serialization/RedmineSerializerFactory.cs @@ -15,6 +15,8 @@ limitations under the License. */ using System; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Xml; namespace Redmine.Net.Api.Serialization; @@ -23,6 +25,16 @@ namespace Redmine.Net.Api.Serialization; /// internal static class RedmineSerializerFactory { + /// + /// Creates an instance of an IRedmineSerializer based on the specified serialization type. + /// + /// The type of serialization, either Xml or Json. + /// + /// An instance of a serializer that implements the IRedmineSerializer interface. + /// + /// + /// Thrown when the specified serialization type is not supported. + /// public static IRedmineSerializer CreateSerializer(SerializationType type) { return type switch diff --git a/src/redmine-net-api/Serialization/SerializationHelper.cs b/src/redmine-net-api/Serialization/SerializationHelper.cs index 225772aa..c54ce852 100644 --- a/src/redmine-net-api/Serialization/SerializationHelper.cs +++ b/src/redmine-net-api/Serialization/SerializationHelper.cs @@ -14,26 +14,45 @@ You may obtain a copy of the License at limitations under the License. */ -using System.Globalization; +using System; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Xml; namespace Redmine.Net.Api.Serialization { /// - /// + /// Provides helper methods for serializing user-related data for communication with the Redmine API. /// internal static class SerializationHelper { /// - /// + /// Serializes the user ID into a format suitable for communication with the Redmine API, + /// based on the specified serializer type. /// - /// - /// - /// + /// The ID of the user to be serialized. + /// The serializer used to format the user ID (e.g., XML or JSON). + /// A serialized representation of the user ID. + /// + /// Thrown when the provided serializer is not recognized or supported. + /// public static string SerializeUserId(int userId, IRedmineSerializer redmineSerializer) { - return redmineSerializer is XmlRedmineSerializer - ? $"{userId.ToString(CultureInfo.InvariantCulture)}" - : $"{{\"user_id\":\"{userId.ToString(CultureInfo.InvariantCulture)}\"}}"; + return redmineSerializer switch + { + XmlRedmineSerializer => $"{userId.ToInvariantString()}", + JsonRedmineSerializer => $"{{\"user_id\":\"{userId.ToInvariantString()}\"}}", + _ => throw new ArgumentOutOfRangeException(nameof(redmineSerializer), redmineSerializer, null) + }; + } + + public static void EnsureDeserializationInputIsNotNullOrWhiteSpace(string input, string paramName, Type type) + { + if (input.IsNullOrWhiteSpace()) + { + throw new RedmineSerializationException($"Could not deserialize null or empty input for type '{type.Name}'.", paramName); + } } } } \ No newline at end of file diff --git a/src/redmine-net-api/Serialization/SerializationType.cs b/src/redmine-net-api/Serialization/SerializationType.cs index decd824c..3830d3fe 100644 --- a/src/redmine-net-api/Serialization/SerializationType.cs +++ b/src/redmine-net-api/Serialization/SerializationType.cs @@ -17,15 +17,17 @@ limitations under the License. namespace Redmine.Net.Api.Serialization { /// - /// + /// Specifies the serialization types supported by the Redmine API. /// public enum SerializationType { /// + /// The XML format. /// Xml, + /// - /// The json + /// The JSON format. /// Json } diff --git a/src/redmine-net-api/Serialization/Xml/CacheKeyFactory.cs b/src/redmine-net-api/Serialization/Xml/CacheKeyFactory.cs index 45f17b12..47cef30d 100644 --- a/src/redmine-net-api/Serialization/Xml/CacheKeyFactory.cs +++ b/src/redmine-net-api/Serialization/Xml/CacheKeyFactory.cs @@ -15,11 +15,11 @@ limitations under the License. */ using System; -using System.Globalization; using System.Text; using System.Xml.Serialization; +using Redmine.Net.Api.Extensions; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Xml { /// /// The CacheKeyFactory extracts a unique signature @@ -46,11 +46,11 @@ public static string Create(Type type, XmlAttributeOverrides overrides, Type[] t var keyBuilder = new StringBuilder(); keyBuilder.Append(type.FullName); keyBuilder.Append( "??" ); - keyBuilder.Append(overrides?.GetHashCode().ToString(CultureInfo.InvariantCulture)); + keyBuilder.Append(overrides?.GetHashCode().ToInvariantString()); keyBuilder.Append( "??" ); keyBuilder.Append(GetTypeArraySignature(types)); keyBuilder.Append("??"); - keyBuilder.Append(root?.GetHashCode().ToString(CultureInfo.InvariantCulture)); + keyBuilder.Append(root?.GetHashCode().ToInvariantString()); keyBuilder.Append("??"); keyBuilder.Append(defaultNamespace); diff --git a/src/redmine-net-api/Serialization/Xml/Extensions/XmlReaderExtensions.cs b/src/redmine-net-api/Serialization/Xml/Extensions/XmlReaderExtensions.cs index 8a57a1e6..3342ed05 100644 --- a/src/redmine-net-api/Serialization/Xml/Extensions/XmlReaderExtensions.cs +++ b/src/redmine-net-api/Serialization/Xml/Extensions/XmlReaderExtensions.cs @@ -20,9 +20,9 @@ limitations under the License. using System.IO; using System.Xml; using System.Xml.Serialization; -using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Extensions; -namespace Redmine.Net.Api.Extensions +namespace Redmine.Net.Api.Serialization.Xml.Extensions { /// /// diff --git a/src/redmine-net-api/Serialization/Xml/Extensions/XmlWriterExtensions.cs b/src/redmine-net-api/Serialization/Xml/Extensions/XmlWriterExtensions.cs index cf33cb1c..b641af40 100644 --- a/src/redmine-net-api/Serialization/Xml/Extensions/XmlWriterExtensions.cs +++ b/src/redmine-net-api/Serialization/Xml/Extensions/XmlWriterExtensions.cs @@ -20,9 +20,11 @@ limitations under the License. using System.Globalization; using System.Xml; using System.Xml.Serialization; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Types; -namespace Redmine.Net.Api.Extensions +namespace Redmine.Net.Api.Serialization.Xml.Extensions { /// /// @@ -47,7 +49,7 @@ public static void WriteIdIfNotNull(this XmlWriter writer, string elementName, I { if (identifiableName != null) { - writer.WriteElementString(elementName, identifiableName.Id.ToString(CultureInfo.InvariantCulture)); + writer.WriteElementString(elementName, identifiableName.Id.ToInvariantString()); } } @@ -160,6 +162,39 @@ public static void WriteArray(this XmlWriter writer, string elementName, IEnumer writer.WriteEndElement(); } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static void WriteArray(this XmlWriter writer, string elementName, IEnumerable collection, string root, string defaultNamespace = null) + { + if (collection == null) + { + return; + } + + var type = typeof(T); + writer.WriteStartElement(elementName); + writer.WriteAttributeString("type", "array"); + + var rootAttribute = new XmlRootAttribute(root); + + var serializer = new XmlSerializer(type, XmlAttributeOverrides, EmptyTypeArray, rootAttribute, + defaultNamespace); + + foreach (var item in collection) + { + serializer.Serialize(writer, item); + } + + writer.WriteEndElement(); + } /// /// Writes the list elements. @@ -232,7 +267,7 @@ public static void WriteArrayStringElement(this XmlWriter writer, string element /// public static void WriteIdOrEmpty(this XmlWriter writer, string elementName, IdentifiableName ident) { - writer.WriteElementString(elementName, ident != null ? ident.Id.ToString(CultureInfo.InvariantCulture) : string.Empty); + writer.WriteElementString(elementName, ident != null ? ident.Id.ToInvariantString() : string.Empty); } /// @@ -267,7 +302,7 @@ public static void WriteIfNotDefaultOrNull(this XmlWriter writer, string elem /// The tag. public static void WriteBoolean(this XmlWriter writer, string elementName, bool value) { - writer.WriteElementString(elementName, value.ToString().ToLowerInv()); + writer.WriteElementString(elementName, value.ToInvariantString()); } /// @@ -285,7 +320,7 @@ public static void WriteValueOrEmpty(this XmlWriter writer, string elementNam } else { - writer.WriteElementString(elementName, val.Value.ToString().ToLowerInv()); + writer.WriteElementString(elementName, val.Value.ToInvariantString()); } } diff --git a/src/redmine-net-api/Serialization/Xml/IXmlSerializerCache.cs b/src/redmine-net-api/Serialization/Xml/IXmlSerializerCache.cs index bfb5b416..a11e2d33 100644 --- a/src/redmine-net-api/Serialization/Xml/IXmlSerializerCache.cs +++ b/src/redmine-net-api/Serialization/Xml/IXmlSerializerCache.cs @@ -17,7 +17,7 @@ limitations under the License. using System; using System.Xml.Serialization; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Xml { internal interface IXmlSerializerCache { diff --git a/src/redmine-net-api/Serialization/Xml/XmlRedmineSerializer.cs b/src/redmine-net-api/Serialization/Xml/XmlRedmineSerializer.cs index 253543c7..e86111df 100644 --- a/src/redmine-net-api/Serialization/Xml/XmlRedmineSerializer.cs +++ b/src/redmine-net-api/Serialization/Xml/XmlRedmineSerializer.cs @@ -18,15 +18,23 @@ limitations under the License. using System.IO; using System.Xml; using System.Xml.Serialization; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Exceptions; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Xml.Extensions; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Xml { internal sealed class XmlRedmineSerializer : IRedmineSerializer { - + private static void EnsureXmlSerializable() + { + if (!typeof(IXmlSerializable).IsAssignableFrom(typeof(T))) + { + throw new RedmineException($"Entity of type '{typeof(T)}' should implement ${nameof(IXmlSerializable)}."); + } + } public XmlRedmineSerializer() : this(new XmlWriterSettings { OmitXmlDeclaration = true @@ -79,6 +87,8 @@ public XmlRedmineSerializer(XmlWriterSettings xmlWriterSettings) #pragma warning restore CA1822 public string Format => RedmineConstants.XML; + + public string ContentType { get; } = RedmineConstants.CONTENT_TYPE_APPLICATION_XML; public string Serialize(T entity) where T : class { @@ -101,39 +111,32 @@ public string Serialize(T entity) where T : class /// private static PagedResults XmlDeserializeList(string xmlResponse, bool onlyCount) where T : class, new() { - if (xmlResponse.IsNullOrWhiteSpace()) + SerializationHelper.EnsureDeserializationInputIsNotNullOrWhiteSpace(xmlResponse, nameof(xmlResponse), typeof(T)); + + using var stringReader = new StringReader(xmlResponse); + using var xmlReader = XmlTextReaderBuilder.Create(stringReader); + while (xmlReader.NodeType == XmlNodeType.None || xmlReader.NodeType == XmlNodeType.XmlDeclaration) { - throw new ArgumentNullException(nameof(xmlResponse), $"Could not deserialize null or empty input for type '{typeof(T).Name}'."); + xmlReader.Read(); } - using (var stringReader = new StringReader(xmlResponse)) - { - using (var xmlReader = XmlTextReaderBuilder.Create(stringReader)) - { - while (xmlReader.NodeType == XmlNodeType.None || xmlReader.NodeType == XmlNodeType.XmlDeclaration) - { - xmlReader.Read(); - } - - var totalItems = xmlReader.ReadAttributeAsInt(RedmineKeys.TOTAL_COUNT); + var totalItems = xmlReader.ReadAttributeAsInt(RedmineKeys.TOTAL_COUNT); - if (onlyCount) - { - return new PagedResults(null, totalItems, 0, 0); - } + if (onlyCount) + { + return new PagedResults(null, totalItems, 0, 0); + } - var offset = xmlReader.ReadAttributeAsInt(RedmineKeys.OFFSET); - var limit = xmlReader.ReadAttributeAsInt(RedmineKeys.LIMIT); - var result = xmlReader.ReadElementContentAsCollection(); - - if (totalItems == 0 && result?.Count > 0) - { - totalItems = result.Count; - } + var offset = xmlReader.ReadAttributeAsInt(RedmineKeys.OFFSET); + var limit = xmlReader.ReadAttributeAsInt(RedmineKeys.LIMIT); + var result = xmlReader.ReadElementContentAsCollection(); - return new PagedResults(result, totalItems, offset, limit); - } + if (totalItems == 0 && result?.Count > 0) + { + totalItems = result.Count; } + + return new PagedResults(result, totalItems, offset, limit); } /// @@ -148,22 +151,18 @@ public string Serialize(T entity) where T : class // ReSharper disable once InconsistentNaming private string ToXML(T entity) where T : class { - if (entity == default(T)) + if (entity == null) { throw new ArgumentNullException(nameof(entity), $"Could not serialize null of type {typeof(T).Name}"); } - using (var stringWriter = new StringWriter()) - { - using (var xmlWriter = XmlWriter.Create(stringWriter, _xmlWriterSettings)) - { - var serializer = new XmlSerializer(typeof(T)); + using var stringWriter = new StringWriter(); + using var xmlWriter = XmlWriter.Create(stringWriter, _xmlWriterSettings); + var serializer = new XmlSerializer(typeof(T)); - serializer.Serialize(xmlWriter, entity); + serializer.Serialize(xmlWriter, entity); - return stringWriter.ToString(); - } - } + return stringWriter.ToString(); } /// @@ -181,27 +180,20 @@ private string ToXML(T entity) where T : class // ReSharper disable once InconsistentNaming private static TOut XmlDeserializeEntity(string xml) where TOut : new() { - if (xml.IsNullOrWhiteSpace()) - { - throw new ArgumentNullException(nameof(xml), $"Could not deserialize null or empty input for type '{typeof(TOut).Name}'."); - } - - using (var textReader = new StringReader(xml)) - { - using (var xmlReader = XmlTextReaderBuilder.Create(textReader)) - { - var serializer = new XmlSerializer(typeof(TOut)); + SerializationHelper.EnsureDeserializationInputIsNotNullOrWhiteSpace(xml, nameof(xml), typeof(TOut)); - var entity = serializer.Deserialize(xmlReader); + using var textReader = new StringReader(xml); + using var xmlReader = XmlTextReaderBuilder.Create(textReader); + var serializer = new XmlSerializer(typeof(TOut)); - if (entity is TOut t) - { - return t; - } + var entity = serializer.Deserialize(xmlReader); - return default; - } + if (entity is TOut t) + { + return t; } + + return default; } } } \ No newline at end of file diff --git a/src/redmine-net-api/Serialization/Xml/XmlSerializerCache.cs b/src/redmine-net-api/Serialization/Xml/XmlSerializerCache.cs index 9a63c8d0..fcd977ba 100644 --- a/src/redmine-net-api/Serialization/Xml/XmlSerializerCache.cs +++ b/src/redmine-net-api/Serialization/Xml/XmlSerializerCache.cs @@ -19,7 +19,7 @@ limitations under the License. using System.Diagnostics; using System.Xml.Serialization; -namespace Redmine.Net.Api.Serialization +namespace Redmine.Net.Api.Serialization.Xml { /// /// diff --git a/src/redmine-net-api/Serialization/Xml/XmlTextReaderBuilder.cs b/src/redmine-net-api/Serialization/Xml/XmlTextReaderBuilder.cs index dc83be34..6a01e8ab 100644 --- a/src/redmine-net-api/Serialization/Xml/XmlTextReaderBuilder.cs +++ b/src/redmine-net-api/Serialization/Xml/XmlTextReaderBuilder.cs @@ -17,7 +17,7 @@ limitations under the License. using System.IO; using System.Xml; -namespace Redmine.Net.Api.Internals +namespace Redmine.Net.Api.Serialization.Xml { /// /// diff --git a/src/redmine-net-api/Types/Attachment.cs b/src/redmine-net-api/Types/Attachment.cs index 49b6476c..56afc0c5 100644 --- a/src/redmine-net-api/Types/Attachment.cs +++ b/src/redmine-net-api/Types/Attachment.cs @@ -16,20 +16,22 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ATTACHMENT)] public sealed class Attachment : Identifiable @@ -123,8 +125,8 @@ public override void ReadXml(XmlReader reader) /// public override void WriteXml(XmlWriter writer) { - writer.WriteElementString(RedmineKeys.DESCRIPTION, Description); writer.WriteElementString(RedmineKeys.FILE_NAME, FileName); + writer.WriteElementString(RedmineKeys.DESCRIPTION, Description); } #endregion diff --git a/src/redmine-net-api/Types/Attachments.cs b/src/redmine-net-api/Types/Attachments.cs index 21655fec..c475d531 100644 --- a/src/redmine-net-api/Types/Attachments.cs +++ b/src/redmine-net-api/Types/Attachments.cs @@ -17,7 +17,9 @@ limitations under the License. using System.Collections.Generic; using System.Globalization; using Newtonsoft.Json; +using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { @@ -43,7 +45,7 @@ public void WriteJson(JsonWriter writer) writer.WriteStartArray(); foreach (var item in this) { - writer.WritePropertyName(item.Key.ToString(CultureInfo.InvariantCulture)); + writer.WritePropertyName(item.Key.ToInvariantString()); item.Value.WriteJson(writer); } writer.WriteEndArray(); diff --git a/src/redmine-net-api/Types/ChangeSet.cs b/src/redmine-net-api/Types/ChangeSet.cs index be873273..adfff4fb 100644 --- a/src/redmine-net-api/Types/ChangeSet.cs +++ b/src/redmine-net-api/Types/ChangeSet.cs @@ -24,13 +24,15 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.CHANGE_SET)] public sealed class ChangeSet : IXmlSerializable, IJsonSerializable, IEquatable ,ICloneable diff --git a/src/redmine-net-api/Types/CustomField.cs b/src/redmine-net-api/Types/CustomField.cs index c99a1490..0c0ca47f 100644 --- a/src/redmine-net-api/Types/CustomField.cs +++ b/src/redmine-net-api/Types/CustomField.cs @@ -17,19 +17,20 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.CUSTOM_FIELD)] public sealed class CustomField : IdentifiableName, IEquatable { @@ -97,17 +98,17 @@ public sealed class CustomField : IdentifiableName, IEquatable /// /// /// - public IList PossibleValues { get; internal set; } + public List PossibleValues { get; internal set; } /// /// /// - public IList Trackers { get; internal set; } + public List Trackers { get; internal set; } /// /// /// - public IList Roles { get; internal set; } + public List Roles { get; internal set; } #endregion #region Implementation of IXmlSerializable diff --git a/src/redmine-net-api/Types/CustomFieldPossibleValue.cs b/src/redmine-net-api/Types/CustomFieldPossibleValue.cs index 4a8854f7..56edabd7 100644 --- a/src/redmine-net-api/Types/CustomFieldPossibleValue.cs +++ b/src/redmine-net-api/Types/CustomFieldPossibleValue.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.POSSIBLE_VALUE)] public sealed class CustomFieldPossibleValue : IXmlSerializable, IJsonSerializable, IEquatable { diff --git a/src/redmine-net-api/Types/CustomFieldRole.cs b/src/redmine-net-api/Types/CustomFieldRole.cs index bfffbc71..7b1b20a9 100644 --- a/src/redmine-net-api/Types/CustomFieldRole.cs +++ b/src/redmine-net-api/Types/CustomFieldRole.cs @@ -23,7 +23,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ROLE)] public sealed class CustomFieldRole : IdentifiableName { diff --git a/src/redmine-net-api/Types/CustomFieldValue.cs b/src/redmine-net-api/Types/CustomFieldValue.cs index 32a70959..1dacf6b8 100644 --- a/src/redmine-net-api/Types/CustomFieldValue.cs +++ b/src/redmine-net-api/Types/CustomFieldValue.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.VALUE)] public class CustomFieldValue : IXmlSerializable diff --git a/src/redmine-net-api/Types/Detail.cs b/src/redmine-net-api/Types/Detail.cs index b4738792..4f69af20 100644 --- a/src/redmine-net-api/Types/Detail.cs +++ b/src/redmine-net-api/Types/Detail.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.DETAIL)] public sealed class Detail : IXmlSerializable diff --git a/src/redmine-net-api/Types/DocumentCategory.cs b/src/redmine-net-api/Types/DocumentCategory.cs index 4e01bc96..c8935cce 100644 --- a/src/redmine-net-api/Types/DocumentCategory.cs +++ b/src/redmine-net-api/Types/DocumentCategory.cs @@ -16,19 +16,19 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 2.2 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.DOCUMENT_CATEGORY)] public sealed class DocumentCategory : IdentifiableName, IEquatable { diff --git a/src/redmine-net-api/Types/Error.cs b/src/redmine-net-api/Types/Error.cs index f5285573..f8632707 100644 --- a/src/redmine-net-api/Types/Error.cs +++ b/src/redmine-net-api/Types/Error.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ERROR)] public sealed class Error : IXmlSerializable, IJsonSerializable, IEquatable { diff --git a/src/redmine-net-api/Types/File.cs b/src/redmine-net-api/Types/File.cs index f4f09e30..491f41d9 100644 --- a/src/redmine-net-api/Types/File.cs +++ b/src/redmine-net-api/Types/File.cs @@ -24,13 +24,16 @@ limitations under the License. using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.FILE)] public sealed class File : Identifiable { diff --git a/src/redmine-net-api/Types/Group.cs b/src/redmine-net-api/Types/Group.cs index 27bc3773..8d3d01d1 100644 --- a/src/redmine-net-api/Types/Group.cs +++ b/src/redmine-net-api/Types/Group.cs @@ -20,16 +20,20 @@ limitations under the License. using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 2.1 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.GROUP)] public sealed class Group : IdentifiableName, IEquatable { @@ -51,19 +55,19 @@ public Group(string name) /// /// Represents the group's users. /// - public IList Users { get; set; } + public List Users { get; set; } /// /// Gets or sets the custom fields. /// /// The custom fields. - public IList CustomFields { get; internal set; } + public List CustomFields { get; internal set; } /// /// Gets or sets the custom fields. /// /// The custom fields. - public IList Memberships { get; internal set; } + public List Memberships { get; internal set; } #endregion #region Implementation of IXmlSerializable diff --git a/src/redmine-net-api/Types/GroupUser.cs b/src/redmine-net-api/Types/GroupUser.cs index 598e9462..4264f82d 100644 --- a/src/redmine-net-api/Types/GroupUser.cs +++ b/src/redmine-net-api/Types/GroupUser.cs @@ -17,6 +17,7 @@ limitations under the License. using System.Diagnostics; using System.Globalization; using System.Xml.Serialization; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Types @@ -24,7 +25,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.USER)] public sealed class GroupUser : IdentifiableName, IValue { @@ -32,7 +33,7 @@ public sealed class GroupUser : IdentifiableName, IValue /// /// /// - public string Value => Id.ToString(CultureInfo.InvariantCulture); + public string Value => Id.ToInvariantString(); #endregion /// diff --git a/src/redmine-net-api/Types/Identifiable.cs b/src/redmine-net-api/Types/Identifiable.cs index 02307d1d..42d789a7 100644 --- a/src/redmine-net-api/Types/Identifiable.cs +++ b/src/redmine-net-api/Types/Identifiable.cs @@ -16,7 +16,6 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; @@ -24,7 +23,7 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; -using NotImplementedException = System.NotImplementedException; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { @@ -32,7 +31,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] public abstract class Identifiable : IXmlSerializable, IJsonSerializable, IEquatable , ICloneable> where T : Identifiable diff --git a/src/redmine-net-api/Types/IdentifiableName.cs b/src/redmine-net-api/Types/IdentifiableName.cs index 2a985283..f320426f 100644 --- a/src/redmine-net-api/Types/IdentifiableName.cs +++ b/src/redmine-net-api/Types/IdentifiableName.cs @@ -21,13 +21,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] public class IdentifiableName : Identifiable , ICloneable { @@ -38,7 +40,19 @@ public class IdentifiableName : Identifiable /// public static T Create(int id) where T: IdentifiableName, new() { - var t = new T (){Id = id}; + var t = new T + { + Id = id + }; + return t; + } + + internal static T Create(int id, string name) where T: IdentifiableName, new() + { + var t = new T + { + Id = id, Name = name + }; return t; } @@ -112,7 +126,7 @@ public override void ReadXml(XmlReader reader) /// public override void WriteXml(XmlWriter writer) { - writer.WriteAttributeString(RedmineKeys.ID, Id.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString(RedmineKeys.ID, Id.ToInvariantString()); writer.WriteAttributeString(RedmineKeys.NAME, Name); } @@ -219,7 +233,24 @@ public override int GetHashCode() return !Equals(left, right); } #endregion + + /// + /// + /// + /// + /// + public static implicit operator string(IdentifiableName identifiableName) => FromIdentifiableName(identifiableName); + /// + /// + /// + /// + /// + public static string FromIdentifiableName(IdentifiableName identifiableName) + { + return identifiableName?.Id.ToInvariantString(); + } + /// /// /// diff --git a/src/redmine-net-api/Types/Include.cs b/src/redmine-net-api/Types/Include.cs new file mode 100644 index 00000000..1c8b2d9c --- /dev/null +++ b/src/redmine-net-api/Types/Include.cs @@ -0,0 +1,147 @@ +namespace Redmine.Net.Api.Types; + +/// +/// +/// + [System.Diagnostics.CodeAnalysis.SuppressMessage( +"Design", +"CA1034:Nested types should not be visible", +Justification = "Deliberately exposed")] + +public static class Include +{ + /// + /// + /// + public static class Group + { + /// + /// + /// + public const string Users = RedmineKeys.USERS; + + /// + /// Adds extra information about user's memberships and roles on the projects + /// + public const string Memberships = RedmineKeys.MEMBERSHIPS; + } + + /// + /// Associated data that can be retrieved + /// + public static class Issue + { + /// + /// Specifies whether to include child issues. + /// This parameter is applicable when retrieving details for a specific issue. + /// Corresponds to the Redmine API include parameter: children. + /// + public const string Children = RedmineKeys.CHILDREN; + + /// + /// Specifies whether to include attachments. + /// This parameter is applicable when retrieving a list of issues or details for a specific issue. + /// Corresponds to the Redmine API include parameter: attachments. + /// + public const string Attachments = RedmineKeys.ATTACHMENTS; + + /// + /// Specifies whether to include issue relations. + /// This parameter is applicable when retrieving a list of issues or details for a specific issue. + /// Corresponds to the Redmine API include parameter: relations. + /// + public const string Relations = RedmineKeys.RELATIONS; + + /// + /// Specifies whether to include associated changesets. + /// This parameter is applicable when retrieving details for a specific issue. + /// Corresponds to the Redmine API include parameter: changesets. + /// + public const string Changesets = RedmineKeys.CHANGE_SETS; + + /// + /// Specifies whether to include journal entries (notes and history). + /// This parameter is applicable when retrieving details for a specific issue. + /// Corresponds to the Redmine API include parameter: journals. + /// + public const string Journals = RedmineKeys.JOURNALS; + + /// + /// Specifies whether to include watchers of the issue. + /// This parameter is applicable when retrieving details for a specific issue. + /// Corresponds to the Redmine API include parameter: watchers. + /// + public const string Watchers = RedmineKeys.WATCHERS; + + /// + /// Specifies whether to include allowed statuses of the issue. + /// This parameter is applicable when retrieving details for a specific issue. + /// Corresponds to the Redmine API include parameter: watchers. + /// Since 5.0.x, Returns the available allowed statuses (the same values as provided in the issue edit form) based on: + /// the issue's current tracker, the issue's current status, and the member's role (the defined workflow); + /// the existence of any open subtask(s); + /// the existence of any open blocking issue(s); + /// the existence of a closed parent issue. + /// + public const string AllowedStatuses = RedmineKeys.ALLOWED_STATUSES; + } + + /// + /// + /// + public static class Project + { + /// + /// + /// + public const string Trackers = RedmineKeys.TRACKERS; + + /// + /// since 2.6.0 + /// + public const string EnabledModules = RedmineKeys.ENABLED_MODULES; + + /// + /// + /// + public const string IssueCategories = RedmineKeys.ISSUE_CATEGORIES; + + /// + /// since 3.4.0 + /// + public const string TimeEntryActivities = RedmineKeys.TIME_ENTRY_ACTIVITIES; + + /// + /// since 4.2.0 + /// + public const string IssueCustomFields = RedmineKeys.ISSUE_CUSTOM_FIELDS; + } + + /// + /// + /// + public static class User + { + /// + /// Adds extra information about user's memberships and roles on the projects + /// + public const string Memberships = RedmineKeys.MEMBERSHIPS; + + /// + /// Adds extra information about user's groups + /// added in 2.1 + /// + public const string Groups = RedmineKeys.GROUPS; + } + + /// + /// + /// + public static class WikiPage + { + /// + /// + /// + public const string Attachments = RedmineKeys.ATTACHMENTS; + } +} \ No newline at end of file diff --git a/src/redmine-net-api/Types/Issue.cs b/src/redmine-net-api/Types/Issue.cs index 14f55a6f..1298f214 100644 --- a/src/redmine-net-api/Types/Issue.cs +++ b/src/redmine-net-api/Types/Issue.cs @@ -21,9 +21,13 @@ limitations under the License. using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { @@ -36,7 +40,7 @@ namespace Redmine.Net.Api.Types /// Possible values: children, attachments, relations, changesets and journals. To fetch multiple associations use comma (e.g ?include=relations,journals). /// See Issue journals for more information. /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE)] public sealed class Issue : Identifiable @@ -56,7 +60,7 @@ public sealed class Issue : public IdentifiableName Tracker { get; set; } /// - /// Gets or sets the status.Possible values: open, closed, * to get open and closed issues, status id + /// Gets or sets the status. Possible values: open, closed, * to get open and closed issues, status id /// /// The status. public IssueStatus Status { get; set; } @@ -133,7 +137,7 @@ public sealed class Issue : /// Gets or sets the custom fields. /// /// The custom fields. - public IList CustomFields { get; set; } + public List CustomFields { get; set; } /// /// Gets or sets the created on. @@ -208,7 +212,7 @@ public sealed class Issue : /// /// The journals. /// - public IList Journals { get; set; } + public List Journals { get; set; } /// /// Gets or sets the change sets. @@ -216,7 +220,7 @@ public sealed class Issue : /// /// The change sets. /// - public IList ChangeSets { get; set; } + public List ChangeSets { get; set; } /// /// Gets or sets the attachments. @@ -224,7 +228,7 @@ public sealed class Issue : /// /// The attachments. /// - public IList Attachments { get; set; } + public List Attachments { get; set; } /// /// Gets or sets the issue relations. @@ -232,7 +236,7 @@ public sealed class Issue : /// /// The issue relations. /// - public IList Relations { get; set; } + public List Relations { get; set; } /// /// Gets or sets the issue children. @@ -241,7 +245,7 @@ public sealed class Issue : /// The issue children. /// NOTE: Only Id, tracker and subject are filled. /// - public IList Children { get; set; } + public List Children { get; set; } /// /// Gets or sets the attachments. @@ -249,12 +253,12 @@ public sealed class Issue : /// /// The attachment. /// - public IList Uploads { get; set; } + public List Uploads { get; set; } /// /// /// - public IList Watchers { get; set; } + public List Watchers { get; set; } /// /// @@ -334,7 +338,7 @@ public override void WriteXml(XmlWriter writer) } writer.WriteElementString(RedmineKeys.DESCRIPTION, Description); - writer.WriteElementString(RedmineKeys.IS_PRIVATE, IsPrivate.ToString(CultureInfo.InvariantCulture).ToLowerInv()); + writer.WriteElementString(RedmineKeys.IS_PRIVATE, IsPrivate.ToInvariantString()); writer.WriteIdIfNotNull(RedmineKeys.PROJECT_ID, Project); writer.WriteIdIfNotNull(RedmineKeys.PRIORITY_ID, Priority); @@ -344,7 +348,6 @@ public override void WriteXml(XmlWriter writer) writer.WriteIdIfNotNull(RedmineKeys.ASSIGNED_TO_ID, AssignedTo); writer.WriteIdIfNotNull(RedmineKeys.PARENT_ISSUE_ID, ParentIssue); writer.WriteIdIfNotNull(RedmineKeys.FIXED_VERSION_ID, FixedVersion); - writer.WriteValueOrEmpty(RedmineKeys.ESTIMATED_HOURS, EstimatedHours); writer.WriteIfNotDefaultOrNull(RedmineKeys.DONE_RATIO, DoneRatio); @@ -452,12 +455,12 @@ public override void WriteJson(JsonWriter writer) if (DoneRatio != null) { - writer.WriteProperty(RedmineKeys.DONE_RATIO, DoneRatio.Value.ToString(CultureInfo.InvariantCulture)); + writer.WriteProperty(RedmineKeys.DONE_RATIO, DoneRatio.Value.ToInvariantString()); } if (SpentHours != null) { - writer.WriteProperty(RedmineKeys.SPENT_HOURS, SpentHours.Value.ToString(CultureInfo.InvariantCulture)); + writer.WriteProperty(RedmineKeys.SPENT_HOURS, SpentHours.Value.ToInvariantString()); } writer.WriteArray(RedmineKeys.UPLOADS, Uploads); @@ -645,11 +648,12 @@ public IdentifiableName AsParent() return IdentifiableName.Create(Id); } - /// - /// + /// Provides a string representation of the object for use in debugging. /// - /// + /// + /// A string that represents the object, formatted for debugging purposes. + /// private string DebuggerDisplay => $"[Issue:Id={Id.ToInvariantString()}, Status={Status?.Name}, Priority={Priority?.Name}, DoneRatio={DoneRatio?.ToString("F", CultureInfo.InvariantCulture)},IsPrivate={IsPrivate.ToInvariantString()}]"; } } \ No newline at end of file diff --git a/src/redmine-net-api/Types/IssueAllowedStatus.cs b/src/redmine-net-api/Types/IssueAllowedStatus.cs index 9a47811a..1fa60dba 100644 --- a/src/redmine-net-api/Types/IssueAllowedStatus.cs +++ b/src/redmine-net-api/Types/IssueAllowedStatus.cs @@ -20,13 +20,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.STATUS)] public sealed class IssueAllowedStatus : IdentifiableName { diff --git a/src/redmine-net-api/Types/IssueCategory.cs b/src/redmine-net-api/Types/IssueCategory.cs index 39c5781c..1dbc3ca5 100644 --- a/src/redmine-net-api/Types/IssueCategory.cs +++ b/src/redmine-net-api/Types/IssueCategory.cs @@ -21,13 +21,16 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE_CATEGORY)] public sealed class IssueCategory : Identifiable { @@ -90,7 +93,7 @@ public override void ReadXml(XmlReader reader) /// public override void WriteXml(XmlWriter writer) { - writer.WriteIdIfNotNull(RedmineKeys.PROJECT_ID, Project); + // writer.WriteIdIfNotNull(RedmineKeys.PROJECT_ID, Project); writer.WriteElementString(RedmineKeys.NAME, Name); writer.WriteIdIfNotNull(RedmineKeys.ASSIGNED_TO_ID, AssignTo); } diff --git a/src/redmine-net-api/Types/IssueChild.cs b/src/redmine-net-api/Types/IssueChild.cs index b6d3fcf1..a6e9adfe 100644 --- a/src/redmine-net-api/Types/IssueChild.cs +++ b/src/redmine-net-api/Types/IssueChild.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE)] public sealed class IssueChild : Identifiable ,ICloneable diff --git a/src/redmine-net-api/Types/IssueCustomField.cs b/src/redmine-net-api/Types/IssueCustomField.cs index da8935c9..0333e832 100644 --- a/src/redmine-net-api/Types/IssueCustomField.cs +++ b/src/redmine-net-api/Types/IssueCustomField.cs @@ -21,15 +21,18 @@ limitations under the License. using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.CUSTOM_FIELD)] public sealed class IssueCustomField : IdentifiableName @@ -41,7 +44,7 @@ public sealed class IssueCustomField : /// Gets or sets the value. /// /// The value. - public IList Values { get; set; } + public List Values { get; set; } /// /// @@ -113,7 +116,7 @@ public override void WriteXml(XmlWriter writer) var itemsCount = Values.Count; - writer.WriteAttributeString(RedmineKeys.ID, Id.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString(RedmineKeys.ID, Id.ToInvariantString()); Multiple = itemsCount > 1; @@ -307,7 +310,7 @@ public override int GetHashCode() /// /// /// - public string Value => Id.ToString(CultureInfo.InvariantCulture); + public string Value => Id.ToInvariantString(); #endregion @@ -326,5 +329,53 @@ public static string GetValue(object item) /// /// private string DebuggerDisplay => $"[IssueCustomField: Id={Id.ToInvariantString()}, Name={Name}, Multiple={Multiple.ToInvariantString()}]"; + + /// + /// + /// + /// + /// + /// + /// + public static IssueCustomField CreateSingle(int id, string name, string value) + { + return new IssueCustomField + { + Id = id, + Name = name, + Values = [new CustomFieldValue { Info = value }] + }; + } + + /// + /// + /// + /// + /// + /// + /// + public static IssueCustomField CreateMultiple(int id, string name, string[] values) + { + var isf = new IssueCustomField + { + Id = id, + Name = name, + Multiple = true, + }; + + if (values is not { Length: > 0 }) + { + return isf; + } + + isf.Values = new List(values.Length); + + foreach (var value in values) + { + isf.Values.Add(new CustomFieldValue { Info = value }); + } + + return isf; + } } } \ No newline at end of file diff --git a/src/redmine-net-api/Types/IssuePriority.cs b/src/redmine-net-api/Types/IssuePriority.cs index 82522462..51ac6dc3 100644 --- a/src/redmine-net-api/Types/IssuePriority.cs +++ b/src/redmine-net-api/Types/IssuePriority.cs @@ -16,19 +16,19 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 2.2 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE_PRIORITY)] public sealed class IssuePriority : IdentifiableName diff --git a/src/redmine-net-api/Types/IssueRelation.cs b/src/redmine-net-api/Types/IssueRelation.cs index f913221b..62ddfeea 100644 --- a/src/redmine-net-api/Types/IssueRelation.cs +++ b/src/redmine-net-api/Types/IssueRelation.cs @@ -16,7 +16,6 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; @@ -24,17 +23,18 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.RELATION)] - public sealed class IssueRelation : - Identifiable - ,ICloneable + public sealed class IssueRelation : Identifiable, ICloneable { #region Properties /// @@ -115,8 +115,8 @@ public override void WriteXml(XmlWriter writer) { AssertValidIssueRelationType(); - writer.WriteElementString(RedmineKeys.ISSUE_TO_ID, IssueToId.ToString(CultureInfo.InvariantCulture)); - writer.WriteElementString(RedmineKeys.RELATION_TYPE, Type.ToString().ToLowerInv()); + writer.WriteElementString(RedmineKeys.ISSUE_TO_ID, IssueToId.ToInvariantString()); + writer.WriteElementString(RedmineKeys.RELATION_TYPE, Type.ToLowerName()); if (Type == IssueRelationType.Precedes || Type == IssueRelationType.Follows) { @@ -137,7 +137,7 @@ public override void WriteJson(JsonWriter writer) using (new JsonObject(writer, RedmineKeys.RELATION)) { writer.WriteProperty(RedmineKeys.ISSUE_TO_ID, IssueToId); - writer.WriteProperty(RedmineKeys.RELATION_TYPE, Type.ToString().ToLowerInv()); + writer.WriteProperty(RedmineKeys.RELATION_TYPE, Type.ToLowerName()); if (Type == IssueRelationType.Precedes || Type == IssueRelationType.Follows) { diff --git a/src/redmine-net-api/Types/IssueRelationType.cs b/src/redmine-net-api/Types/IssueRelationType.cs index fff16392..01d06292 100644 --- a/src/redmine-net-api/Types/IssueRelationType.cs +++ b/src/redmine-net-api/Types/IssueRelationType.cs @@ -68,13 +68,13 @@ public enum IssueRelationType /// /// - [XmlEnum("copied_to")] + [XmlEnum(RedmineKeys.COPIED_TO)] CopiedTo, /// /// /// - [XmlEnum("copied_from")] + [XmlEnum(RedmineKeys.COPIED_FROM)] CopiedFrom } } \ No newline at end of file diff --git a/src/redmine-net-api/Types/IssueStatus.cs b/src/redmine-net-api/Types/IssueStatus.cs index 25d2a30a..08c7553a 100644 --- a/src/redmine-net-api/Types/IssueStatus.cs +++ b/src/redmine-net-api/Types/IssueStatus.cs @@ -16,27 +16,40 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE_STATUS)] public sealed class IssueStatus : IdentifiableName, IEquatable, ICloneable { + /// + /// + /// public IssueStatus() { } + /// + /// + /// + /// + public IssueStatus(int id) + { + Id = id; + } + /// /// /// diff --git a/src/redmine-net-api/Types/Journal.cs b/src/redmine-net-api/Types/Journal.cs index bf016b1b..916967a1 100644 --- a/src/redmine-net-api/Types/Journal.cs +++ b/src/redmine-net-api/Types/Journal.cs @@ -23,13 +23,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.JOURNAL)] public sealed class Journal : Identifiable @@ -81,7 +83,7 @@ public sealed class Journal : /// /// The details. /// - public IList Details { get; internal set; } + public List Details { get; internal set; } /// /// diff --git a/src/redmine-net-api/Types/Membership.cs b/src/redmine-net-api/Types/Membership.cs index 4b563614..d03fa95f 100644 --- a/src/redmine-net-api/Types/Membership.cs +++ b/src/redmine-net-api/Types/Membership.cs @@ -21,13 +21,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Only the roles can be updated, the project and the user of a membership are read-only. /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.MEMBERSHIP)] public sealed class Membership : Identifiable { @@ -51,7 +53,7 @@ public sealed class Membership : Identifiable /// Gets or sets the type. /// /// The type. - public IList Roles { get; internal set; } + public List Roles { get; internal set; } #endregion #region Implementation of IXmlSerialization diff --git a/src/redmine-net-api/Types/MembershipRole.cs b/src/redmine-net-api/Types/MembershipRole.cs index 1de8fbfc..fcfab110 100644 --- a/src/redmine-net-api/Types/MembershipRole.cs +++ b/src/redmine-net-api/Types/MembershipRole.cs @@ -20,15 +20,18 @@ limitations under the License. using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ROLE)] public sealed class MembershipRole : IdentifiableName, IEquatable, IValue { @@ -100,7 +103,7 @@ public override void ReadJson(JsonReader reader) /// public override void WriteJson(JsonWriter writer) { - writer.WriteProperty(RedmineKeys.ID, Id.ToString(CultureInfo.InvariantCulture)); + writer.WriteProperty(RedmineKeys.ID, Id.ToInvariantString()); } #endregion @@ -172,7 +175,7 @@ public override int GetHashCode() /// /// /// - public string Value => Id.ToString(CultureInfo.InvariantCulture); + public string Value => Id.ToInvariantString(); #endregion /// diff --git a/src/redmine-net-api/Types/MyAccount.cs b/src/redmine-net-api/Types/MyAccount.cs index 806123b4..570c5d67 100644 --- a/src/redmine-net-api/Types/MyAccount.cs +++ b/src/redmine-net-api/Types/MyAccount.cs @@ -17,13 +17,15 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { @@ -31,7 +33,7 @@ namespace Redmine.Net.Api.Types /// /// /// Availability 4.1 - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.USER)] public sealed class MyAccount : Identifiable { diff --git a/src/redmine-net-api/Types/MyAccountCustomField.cs b/src/redmine-net-api/Types/MyAccountCustomField.cs index 55372989..3015296a 100644 --- a/src/redmine-net-api/Types/MyAccountCustomField.cs +++ b/src/redmine-net-api/Types/MyAccountCustomField.cs @@ -21,13 +21,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.CUSTOM_FIELD)] public sealed class MyAccountCustomField : IdentifiableName, IEquatable { diff --git a/src/redmine-net-api/Types/News.cs b/src/redmine-net-api/Types/News.cs index c989c6f2..6e830f72 100644 --- a/src/redmine-net-api/Types/News.cs +++ b/src/redmine-net-api/Types/News.cs @@ -17,20 +17,22 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.1 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.NEWS)] public sealed class News : Identifiable { diff --git a/src/redmine-net-api/Types/NewsComment.cs b/src/redmine-net-api/Types/NewsComment.cs index 86eca5ed..b80953e4 100644 --- a/src/redmine-net-api/Types/NewsComment.cs +++ b/src/redmine-net-api/Types/NewsComment.cs @@ -27,7 +27,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.COMMENT)] public sealed class NewsComment: Identifiable { diff --git a/src/redmine-net-api/Types/Permission.cs b/src/redmine-net-api/Types/Permission.cs index 8d300411..7612ecef 100644 --- a/src/redmine-net-api/Types/Permission.cs +++ b/src/redmine-net-api/Types/Permission.cs @@ -22,13 +22,14 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.PERMISSION)] #pragma warning disable CA1711 public sealed class Permission : IXmlSerializable, IJsonSerializable, IEquatable diff --git a/src/redmine-net-api/Types/Project.cs b/src/redmine-net-api/Types/Project.cs index 798ab732..5ff1f11f 100644 --- a/src/redmine-net-api/Types/Project.cs +++ b/src/redmine-net-api/Types/Project.cs @@ -17,20 +17,23 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.0 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.PROJECT)] public sealed class Project : IdentifiableName, IEquatable { @@ -105,7 +108,7 @@ public sealed class Project : IdentifiableName, IEquatable /// The trackers. /// /// Available in Redmine starting with 2.6.0 version. - public IList Trackers { get; set; } + public List Trackers { get; set; } /// /// Gets or sets the enabled modules. @@ -114,15 +117,17 @@ public sealed class Project : IdentifiableName, IEquatable /// The enabled modules. /// /// Available in Redmine starting with 2.6.0 version. - public IList EnabledModules { get; set; } + public List EnabledModules { get; set; } /// - /// Gets or sets the custom fields. + /// /// - /// - /// The custom fields. - /// - public IList CustomFields { get; set; } + public List IssueCustomFields { get; set; } + + /// + /// + /// + public List CustomFieldValues { get; set; } /// /// Gets the issue categories. @@ -130,14 +135,14 @@ public sealed class Project : IdentifiableName, IEquatable /// /// The issue categories. /// - /// Available in Redmine starting with 2.6.0 version. - public IList IssueCategories { get; internal set; } + /// Available in Redmine starting with the 2.6.0 version. + public List IssueCategories { get; internal set; } /// /// Gets the time entry activities. /// - /// Available in Redmine starting with 3.4.0 version. - public IList TimeEntryActivities { get; internal set; } + /// Available in Redmine starting with the 3.4.0 version. + public List TimeEntryActivities { get; internal set; } /// /// @@ -170,7 +175,7 @@ public override void ReadXml(XmlReader reader) { case RedmineKeys.ID: Id = reader.ReadElementContentAsInt(); break; case RedmineKeys.CREATED_ON: CreatedOn = reader.ReadElementContentAsNullableDateTime(); break; - case RedmineKeys.CUSTOM_FIELDS: CustomFields = reader.ReadElementContentAsCollection(); break; + case RedmineKeys.CUSTOM_FIELDS: IssueCustomFields = reader.ReadElementContentAsCollection(); break; case RedmineKeys.DESCRIPTION: Description = reader.ReadElementContentAsString(); break; case RedmineKeys.ENABLED_MODULES: EnabledModules = reader.ReadElementContentAsCollection(); break; case RedmineKeys.HOMEPAGE: HomePage = reader.ReadElementContentAsString(); break; @@ -198,30 +203,28 @@ public override void WriteXml(XmlWriter writer) { writer.WriteElementString(RedmineKeys.NAME, Name); writer.WriteElementString(RedmineKeys.IDENTIFIER, Identifier); - writer.WriteIfNotDefaultOrNull(RedmineKeys.DESCRIPTION, Description); - writer.WriteBoolean(RedmineKeys.INHERIT_MEMBERS, InheritMembers); - writer.WriteBoolean(RedmineKeys.IS_PUBLIC, IsPublic); writer.WriteIfNotDefaultOrNull(RedmineKeys.HOMEPAGE, HomePage); - + writer.WriteBoolean(RedmineKeys.IS_PUBLIC, IsPublic); writer.WriteIdIfNotNull(RedmineKeys.PARENT_ID, Parent); + writer.WriteBoolean(RedmineKeys.INHERIT_MEMBERS, InheritMembers); + + //It works only when the new project is a subproject, and it inherits the members. + writer.WriteIdIfNotNull(RedmineKeys.DEFAULT_ASSIGNED_TO_ID, DefaultAssignee); + //It works only with existing shared versions. + writer.WriteIdIfNotNull(RedmineKeys.DEFAULT_VERSION_ID, DefaultVersion); writer.WriteRepeatableElement(RedmineKeys.TRACKER_IDS, (IEnumerable)Trackers); writer.WriteRepeatableElement(RedmineKeys.ENABLED_MODULE_NAMES, (IEnumerable)EnabledModules); - + writer.WriteRepeatableElement(RedmineKeys.ISSUE_CUSTOM_FIELD_IDS, (IEnumerable)IssueCustomFields); if (Id == 0) { - writer.WriteRepeatableElement(RedmineKeys.ISSUE_CUSTOM_FIELD_IDS, (IEnumerable)CustomFields); - return; + writer.WriteArray(RedmineKeys.CUSTOM_FIELD_VALUES, CustomFieldValues); } - - writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); } #endregion #region Implementation of IJsonSerialization - - /// /// /// @@ -244,7 +247,7 @@ public override void ReadJson(JsonReader reader) { case RedmineKeys.ID: Id = reader.ReadAsInt(); break; case RedmineKeys.CREATED_ON: CreatedOn = reader.ReadAsDateTime(); break; - case RedmineKeys.CUSTOM_FIELDS: CustomFields = reader.ReadAsCollection(); break; + case RedmineKeys.CUSTOM_FIELDS: IssueCustomFields = reader.ReadAsCollection(); break; case RedmineKeys.DESCRIPTION: Description = reader.ReadAsString(); break; case RedmineKeys.ENABLED_MODULES: EnabledModules = reader.ReadAsCollection(); break; case RedmineKeys.HOMEPAGE: HomePage = reader.ReadAsString(); break; @@ -280,16 +283,19 @@ public override void WriteJson(JsonWriter writer) writer.WriteBoolean(RedmineKeys.INHERIT_MEMBERS, InheritMembers); writer.WriteBoolean(RedmineKeys.IS_PUBLIC, IsPublic); writer.WriteIdIfNotNull(RedmineKeys.PARENT_ID, Parent); + + //It works only when the new project is a subproject, and it inherits the members. + writer.WriteIdIfNotNull(RedmineKeys.DEFAULT_ASSIGNED_TO_ID, DefaultAssignee); + //It works only with existing shared versions. + writer.WriteIdIfNotNull(RedmineKeys.DEFAULT_VERSION_ID, DefaultVersion); + writer.WriteRepeatableElement(RedmineKeys.TRACKER_IDS, (IEnumerable)Trackers); writer.WriteRepeatableElement(RedmineKeys.ENABLED_MODULE_NAMES, (IEnumerable)EnabledModules); - + writer.WriteRepeatableElement(RedmineKeys.ISSUE_CUSTOM_FIELD_IDS, (IEnumerable)IssueCustomFields); if (Id == 0) { - writer.WriteRepeatableElement(RedmineKeys.ISSUE_CUSTOM_FIELD_IDS, (IEnumerable)CustomFields); - return; + writer.WriteArray(RedmineKeys.CUSTOM_FIELD_VALUES, CustomFieldValues); } - - writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); } } #endregion @@ -321,7 +327,8 @@ public bool Equals(Project other) && DefaultVersion == other.DefaultVersion && Parent == other.Parent && (Trackers?.Equals(other.Trackers) ?? other.Trackers == null) - && (CustomFields?.Equals(other.CustomFields) ?? other.CustomFields == null) + && (IssueCustomFields?.Equals(other.IssueCustomFields) ?? other.IssueCustomFields == null) + && (CustomFieldValues?.Equals(other.CustomFieldValues) ?? other.CustomFieldValues == null) && (IssueCategories?.Equals(other.IssueCategories) ?? other.IssueCategories == null) && (EnabledModules?.Equals(other.EnabledModules) ?? other.EnabledModules == null) && (TimeEntryActivities?.Equals(other.TimeEntryActivities) ?? other.TimeEntryActivities == null); @@ -359,7 +366,8 @@ public override int GetHashCode() hashCode = HashCodeHelper.GetHashCode(IsPublic, hashCode); hashCode = HashCodeHelper.GetHashCode(InheritMembers, hashCode); hashCode = HashCodeHelper.GetHashCode(Trackers, hashCode); - hashCode = HashCodeHelper.GetHashCode(CustomFields, hashCode); + hashCode = HashCodeHelper.GetHashCode(IssueCustomFields, hashCode); + hashCode = HashCodeHelper.GetHashCode(CustomFieldValues, hashCode); hashCode = HashCodeHelper.GetHashCode(IssueCategories, hashCode); hashCode = HashCodeHelper.GetHashCode(EnabledModules, hashCode); hashCode = HashCodeHelper.GetHashCode(TimeEntryActivities, hashCode); diff --git a/src/redmine-net-api/Types/ProjectEnabledModule.cs b/src/redmine-net-api/Types/ProjectEnabledModule.cs index 02d7f60c..eb91f016 100644 --- a/src/redmine-net-api/Types/ProjectEnabledModule.cs +++ b/src/redmine-net-api/Types/ProjectEnabledModule.cs @@ -17,6 +17,7 @@ limitations under the License. using System; using System.Diagnostics; using System.Xml.Serialization; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Types @@ -24,7 +25,7 @@ namespace Redmine.Net.Api.Types /// /// the module name: boards, calendar, documents, files, gant, issue_tracking, news, repository, time_tracking, wiki. /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ENABLED_MODULE)] public sealed class ProjectEnabledModule : IdentifiableName, IValue { diff --git a/src/redmine-net-api/Types/ProjectIssueCategory.cs b/src/redmine-net-api/Types/ProjectIssueCategory.cs index 2ef3b006..6f214692 100644 --- a/src/redmine-net-api/Types/ProjectIssueCategory.cs +++ b/src/redmine-net-api/Types/ProjectIssueCategory.cs @@ -23,7 +23,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ISSUE_CATEGORY)] public sealed class ProjectIssueCategory : IdentifiableName { diff --git a/src/redmine-net-api/Types/ProjectMembership.cs b/src/redmine-net-api/Types/ProjectMembership.cs index cd0cb010..8528a7de 100644 --- a/src/redmine-net-api/Types/ProjectMembership.cs +++ b/src/redmine-net-api/Types/ProjectMembership.cs @@ -22,6 +22,9 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { @@ -34,7 +37,7 @@ namespace Redmine.Net.Api.Types /// PUT - Updates the membership of given :id. Only the roles can be updated, the project and the user of a membership are read-only. /// DELETE - Deletes a memberships. Memberships inherited from a group membership can not be deleted. You must delete the group membership. /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.MEMBERSHIP)] public sealed class ProjectMembership : Identifiable { @@ -65,7 +68,7 @@ public sealed class ProjectMembership : Identifiable /// Gets or sets the type. /// /// The type. - public IList Roles { get; set; } + public List Roles { get; set; } #endregion #region Implementation of IXmlSerialization @@ -102,8 +105,12 @@ public override void ReadXml(XmlReader reader) /// public override void WriteXml(XmlWriter writer) { - writer.WriteIdIfNotNull(RedmineKeys.USER_ID, User); - writer.WriteArray(RedmineKeys.ROLE_IDS, Roles, typeof(MembershipRole), RedmineKeys.ROLE_ID); + if (Id <= 0) + { + writer.WriteIdIfNotNull(RedmineKeys.USER_ID, User); + } + + writer.WriteArray(RedmineKeys.ROLE_IDS, Roles, root: RedmineKeys.ROLE_ID); } #endregion @@ -146,8 +153,12 @@ public override void WriteJson(JsonWriter writer) { using (new JsonObject(writer, RedmineKeys.MEMBERSHIP)) { - writer.WriteIdIfNotNull(RedmineKeys.USER_ID, User); - writer.WriteRepeatableElement(RedmineKeys.ROLE_IDS, (IEnumerable)Roles); + if (Id <= 0) + { + writer.WriteIdIfNotNull(RedmineKeys.USER_ID, User); + } + + writer.WriteArray(RedmineKeys.ROLE_IDS, Roles); } } #endregion diff --git a/src/redmine-net-api/Types/ProjectTimeEntryActivity.cs b/src/redmine-net-api/Types/ProjectTimeEntryActivity.cs index eded97b3..823de240 100644 --- a/src/redmine-net-api/Types/ProjectTimeEntryActivity.cs +++ b/src/redmine-net-api/Types/ProjectTimeEntryActivity.cs @@ -23,7 +23,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TIME_ENTRY_ACTIVITY)] public sealed class ProjectTimeEntryActivity : IdentifiableName { diff --git a/src/redmine-net-api/Types/ProjectTracker.cs b/src/redmine-net-api/Types/ProjectTracker.cs index fa026ee7..e5fc918d 100644 --- a/src/redmine-net-api/Types/ProjectTracker.cs +++ b/src/redmine-net-api/Types/ProjectTracker.cs @@ -17,6 +17,7 @@ limitations under the License. using System.Diagnostics; using System.Globalization; using System.Xml.Serialization; +using Redmine.Net.Api.Common; using Redmine.Net.Api.Extensions; namespace Redmine.Net.Api.Types @@ -24,7 +25,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TRACKER)] public sealed class ProjectTracker : IdentifiableName, IValue { @@ -57,7 +58,7 @@ internal ProjectTracker(int trackerId) /// /// /// - public string Value => Id.ToString(CultureInfo.InvariantCulture); + public string Value => Id.ToInvariantString(); #endregion diff --git a/src/redmine-net-api/Types/Query.cs b/src/redmine-net-api/Types/Query.cs index 09dc4f56..d77f821d 100644 --- a/src/redmine-net-api/Types/Query.cs +++ b/src/redmine-net-api/Types/Query.cs @@ -16,19 +16,20 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.QUERY)] public sealed class Query : IdentifiableName, IEquatable { diff --git a/src/redmine-net-api/Types/Role.cs b/src/redmine-net-api/Types/Role.cs index fd9dbab2..62a3ec29 100644 --- a/src/redmine-net-api/Types/Role.cs +++ b/src/redmine-net-api/Types/Role.cs @@ -22,13 +22,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.4 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.ROLE)] public sealed class Role : IdentifiableName, IEquatable { @@ -39,7 +41,7 @@ public sealed class Role : IdentifiableName, IEquatable /// /// The issue relations. /// - public IList Permissions { get; internal set; } + public List Permissions { get; internal set; } /// /// diff --git a/src/redmine-net-api/Types/Search.cs b/src/redmine-net-api/Types/Search.cs index a0ec1835..0dec7001 100644 --- a/src/redmine-net-api/Types/Search.cs +++ b/src/redmine-net-api/Types/Search.cs @@ -16,7 +16,6 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; @@ -24,13 +23,16 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.RESULT)] public sealed class Search: IXmlSerializable, IJsonSerializable, IEquatable { diff --git a/src/redmine-net-api/Types/TimeEntry.cs b/src/redmine-net-api/Types/TimeEntry.cs index bdc8cd64..f3580a49 100644 --- a/src/redmine-net-api/Types/TimeEntry.cs +++ b/src/redmine-net-api/Types/TimeEntry.cs @@ -24,6 +24,9 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types @@ -31,7 +34,7 @@ namespace Redmine.Net.Api.Types /// /// Availability 1.1 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TIME_ENTRY)] public sealed class TimeEntry : Identifiable , ICloneable @@ -103,7 +106,7 @@ public string Comments /// Gets or sets the custom fields. /// /// The custom fields. - public IList CustomFields { get; set; } + public List CustomFields { get; set; } #endregion #region Implementation of IXmlSerialization diff --git a/src/redmine-net-api/Types/TimeEntryActivity.cs b/src/redmine-net-api/Types/TimeEntryActivity.cs index dc571fff..2e25bc03 100644 --- a/src/redmine-net-api/Types/TimeEntryActivity.cs +++ b/src/redmine-net-api/Types/TimeEntryActivity.cs @@ -16,19 +16,19 @@ limitations under the License. using System; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 2.2 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TIME_ENTRY_ACTIVITY)] public sealed class TimeEntryActivity : IdentifiableName, IEquatable { diff --git a/src/redmine-net-api/Types/Tracker.cs b/src/redmine-net-api/Types/Tracker.cs index be916923..7f77249f 100644 --- a/src/redmine-net-api/Types/Tracker.cs +++ b/src/redmine-net-api/Types/Tracker.cs @@ -22,13 +22,15 @@ limitations under the License. using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TRACKER)] public class Tracker : IdentifiableName, IEquatable { diff --git a/src/redmine-net-api/Types/TrackerCoreField.cs b/src/redmine-net-api/Types/TrackerCoreField.cs index 2677a1eb..ec9e9360 100644 --- a/src/redmine-net-api/Types/TrackerCoreField.cs +++ b/src/redmine-net-api/Types/TrackerCoreField.cs @@ -6,13 +6,14 @@ using Newtonsoft.Json; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.FIELD)] public sealed class TrackerCoreField: IXmlSerializable, IJsonSerializable, IEquatable { diff --git a/src/redmine-net-api/Types/TrackerCustomField.cs b/src/redmine-net-api/Types/TrackerCustomField.cs index 8464d220..6dd1d213 100644 --- a/src/redmine-net-api/Types/TrackerCustomField.cs +++ b/src/redmine-net-api/Types/TrackerCustomField.cs @@ -19,13 +19,15 @@ limitations under the License. using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.TRACKER)] public sealed class TrackerCustomField : Tracker { diff --git a/src/redmine-net-api/Types/Upload.cs b/src/redmine-net-api/Types/Upload.cs index 12d57053..e2815836 100644 --- a/src/redmine-net-api/Types/Upload.cs +++ b/src/redmine-net-api/Types/Upload.cs @@ -23,13 +23,15 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; namespace Redmine.Net.Api.Types { /// /// Support for adding attachments through the REST API is added in Redmine 1.4.0. /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.UPLOAD)] public sealed class Upload : IXmlSerializable, IJsonSerializable, IEquatable , ICloneable diff --git a/src/redmine-net-api/Types/User.cs b/src/redmine-net-api/Types/User.cs index 19679607..0afc34ad 100644 --- a/src/redmine-net-api/Types/User.cs +++ b/src/redmine-net-api/Types/User.cs @@ -17,20 +17,21 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; -using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.1 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.USER)] public sealed class User : Identifiable { @@ -115,6 +116,10 @@ public sealed class User : Identifiable /// public bool MustChangePassword { get; set; } + /// + /// + /// + public bool GeneratePassword { get; set; } /// /// @@ -152,9 +157,15 @@ public sealed class User : Identifiable /// Gets or sets the user's mail_notification. /// /// - /// only_my_events, only_assigned, [...] + /// only_my_events, only_assigned, only_owner /// public string MailNotification { get; set; } + + /// + /// Send account information to the user + /// + public bool SendInformation { get; set; } + #endregion #region Implementation of IXmlSerialization @@ -207,32 +218,33 @@ public override void ReadXml(XmlReader reader) public override void WriteXml(XmlWriter writer) { writer.WriteElementString(RedmineKeys.LOGIN, Login); - writer.WriteElementString(RedmineKeys.FIRST_NAME, FirstName); - writer.WriteElementString(RedmineKeys.LAST_NAME, LastName); - writer.WriteElementString(RedmineKeys.MAIL, Email); - if(!string.IsNullOrEmpty(MailNotification)) - { - writer.WriteElementString(RedmineKeys.MAIL_NOTIFICATION, MailNotification); - } - - if (!string.IsNullOrEmpty(Password)) + if (!Password.IsNullOrWhiteSpace()) { writer.WriteElementString(RedmineKeys.PASSWORD, Password); } - + + writer.WriteElementString(RedmineKeys.FIRST_NAME, FirstName); + writer.WriteElementString(RedmineKeys.LAST_NAME, LastName); + writer.WriteElementString(RedmineKeys.MAIL, Email); + if(AuthenticationModeId.HasValue) { writer.WriteValueOrEmpty(RedmineKeys.AUTH_SOURCE_ID, AuthenticationModeId); } + if(!MailNotification.IsNullOrWhiteSpace()) + { + writer.WriteElementString(RedmineKeys.MAIL_NOTIFICATION, MailNotification); + } + writer.WriteBoolean(RedmineKeys.MUST_CHANGE_PASSWORD, MustChangePassword); - writer.WriteElementString(RedmineKeys.STATUS, ((int)Status).ToString(CultureInfo.InvariantCulture)); + writer.WriteBoolean(RedmineKeys.GENERATE_PASSWORD, GeneratePassword); + writer.WriteBoolean(RedmineKeys.SEND_INFORMATION, SendInformation); - if(CustomFields != null) - { - writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); - } + writer.WriteElementString(RedmineKeys.STATUS, ((int)Status).ToInvariantString()); + + writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); } #endregion @@ -291,32 +303,33 @@ public override void WriteJson(JsonWriter writer) using (new JsonObject(writer, RedmineKeys.USER)) { writer.WriteProperty(RedmineKeys.LOGIN, Login); - writer.WriteProperty(RedmineKeys.FIRST_NAME, FirstName); - writer.WriteProperty(RedmineKeys.LAST_NAME, LastName); - writer.WriteProperty(RedmineKeys.MAIL, Email); - if(!string.IsNullOrEmpty(MailNotification)) - { - writer.WriteProperty(RedmineKeys.MAIL_NOTIFICATION, MailNotification); - } - if (!string.IsNullOrEmpty(Password)) { writer.WriteProperty(RedmineKeys.PASSWORD, Password); } - + + writer.WriteProperty(RedmineKeys.FIRST_NAME, FirstName); + writer.WriteProperty(RedmineKeys.LAST_NAME, LastName); + writer.WriteProperty(RedmineKeys.MAIL, Email); + if(AuthenticationModeId.HasValue) { writer.WriteValueOrEmpty(RedmineKeys.AUTH_SOURCE_ID, AuthenticationModeId); } + + if(!MailNotification.IsNullOrWhiteSpace()) + { + writer.WriteProperty(RedmineKeys.MAIL_NOTIFICATION, MailNotification); + } writer.WriteBoolean(RedmineKeys.MUST_CHANGE_PASSWORD, MustChangePassword); - writer.WriteProperty(RedmineKeys.STATUS, ((int)Status).ToString(CultureInfo.InvariantCulture)); + writer.WriteBoolean(RedmineKeys.GENERATE_PASSWORD, GeneratePassword); + writer.WriteBoolean(RedmineKeys.SEND_INFORMATION, SendInformation); + + writer.WriteProperty(RedmineKeys.STATUS, ((int)Status).ToInvariantString()); - if(CustomFields != null) - { - writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); - } + writer.WriteArray(RedmineKeys.CUSTOM_FIELDS, CustomFields); } } #endregion @@ -344,6 +357,8 @@ public override bool Equals(User other) && LastLoginOn == other.LastLoginOn && Status == other.Status && MustChangePassword == other.MustChangePassword + && GeneratePassword == other.GeneratePassword + && SendInformation == other.SendInformation && IsAdmin == other.IsAdmin && PasswordChangedOn == other.PasswordChangedOn && UpdatedOn == other.UpdatedOn @@ -394,6 +409,8 @@ public override int GetHashCode() hashCode = HashCodeHelper.GetHashCode(CustomFields, hashCode); hashCode = HashCodeHelper.GetHashCode(Memberships, hashCode); hashCode = HashCodeHelper.GetHashCode(Groups, hashCode); + hashCode = HashCodeHelper.GetHashCode(GeneratePassword, hashCode); + hashCode = HashCodeHelper.GetHashCode(SendInformation, hashCode); return hashCode; } } @@ -425,7 +442,6 @@ public override int GetHashCode() /// /// /// - private string DebuggerDisplay => $"[User: Id={Id.ToInvariantString()}, Login={Login}, IsAdmin={IsAdmin.ToString(CultureInfo.InvariantCulture)}, Status={Status:G}]"; - + private string DebuggerDisplay => $"[User: Id={Id.ToInvariantString()}, Login={Login}, IsAdmin={IsAdmin.ToInvariantString()}, Status={Status:G}]"; } } \ No newline at end of file diff --git a/src/redmine-net-api/Types/UserGroup.cs b/src/redmine-net-api/Types/UserGroup.cs index 660c9051..55697d26 100644 --- a/src/redmine-net-api/Types/UserGroup.cs +++ b/src/redmine-net-api/Types/UserGroup.cs @@ -23,7 +23,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.GROUP)] public sealed class UserGroup : IdentifiableName { diff --git a/src/redmine-net-api/Types/UserStatus.cs b/src/redmine-net-api/Types/UserStatus.cs index 86542357..c14b6a38 100644 --- a/src/redmine-net-api/Types/UserStatus.cs +++ b/src/redmine-net-api/Types/UserStatus.cs @@ -21,10 +21,6 @@ namespace Redmine.Net.Api.Types /// public enum UserStatus { - /// - /// - /// - StatusAnonymous = 0, /// /// /// diff --git a/src/redmine-net-api/Types/Version.cs b/src/redmine-net-api/Types/Version.cs index 2e4dd096..4d6f9ac0 100644 --- a/src/redmine-net-api/Types/Version.cs +++ b/src/redmine-net-api/Types/Version.cs @@ -17,29 +17,26 @@ limitations under the License. using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 1.3 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.VERSION)] - public sealed class Version : Identifiable + public sealed class Version : IdentifiableName, IEquatable { #region Properties - /// - /// Gets or sets the name. - /// - public string Name { get; set; } - /// /// Gets the project. /// @@ -101,7 +98,7 @@ public sealed class Version : Identifiable /// Gets the custom fields. /// /// The custom fields. - public IList CustomFields { get; internal set; } + public List CustomFields { get; internal set; } #endregion #region Implementation of IXmlSerializable @@ -123,8 +120,18 @@ public override void ReadXml(XmlReader reader) case RedmineKeys.DUE_DATE: DueDate = reader.ReadElementContentAsNullableDateTime(); break; case RedmineKeys.NAME: Name = reader.ReadElementContentAsString(); break; case RedmineKeys.PROJECT: Project = new IdentifiableName(reader); break; - case RedmineKeys.SHARING: Sharing = (VersionSharing)Enum.Parse(typeof(VersionSharing), reader.ReadElementContentAsString(), true); break; - case RedmineKeys.STATUS: Status = (VersionStatus)Enum.Parse(typeof(VersionStatus), reader.ReadElementContentAsString(), true); break; + case RedmineKeys.SHARING: Sharing = +#if NETFRAMEWORK + (VersionSharing)Enum.Parse(typeof(VersionSharing), reader.ReadElementContentAsString(), true); break; +#else + Enum.Parse(reader.ReadElementContentAsString(), true); break; +#endif + case RedmineKeys.STATUS: Status = +#if NETFRAMEWORK + (VersionStatus)Enum.Parse(typeof(VersionStatus), reader.ReadElementContentAsString(), true); break; +#else + Enum.Parse(reader.ReadElementContentAsString(), true); break; +#endif case RedmineKeys.UPDATED_ON: UpdatedOn = reader.ReadElementContentAsNullableDateTime(); break; case RedmineKeys.WIKI_PAGE_TITLE: WikiPageTitle = reader.ReadElementContentAsString(); break; case RedmineKeys.ESTIMATED_HOURS: EstimatedHours = reader.ReadElementContentAsNullableFloat(); break; @@ -141,10 +148,10 @@ public override void ReadXml(XmlReader reader) public override void WriteXml(XmlWriter writer) { writer.WriteElementString(RedmineKeys.NAME, Name); - writer.WriteElementString(RedmineKeys.STATUS, Status.ToInvariantString()); + writer.WriteElementString(RedmineKeys.STATUS, Status.ToLowerName()); if (Sharing != VersionSharing.Unknown) { - writer.WriteElementString(RedmineKeys.SHARING, Sharing.ToInvariantString()); + writer.WriteElementString(RedmineKeys.SHARING, Sharing.ToLowerName()); } writer.WriteDateOrEmpty(RedmineKeys.DUE_DATE, DueDate); @@ -185,8 +192,18 @@ public override void ReadJson(JsonReader reader) case RedmineKeys.DUE_DATE: DueDate = reader.ReadAsDateTime(); break; case RedmineKeys.NAME: Name = reader.ReadAsString(); break; case RedmineKeys.PROJECT: Project = new IdentifiableName(reader); break; - case RedmineKeys.SHARING: Sharing = (VersionSharing)Enum.Parse(typeof(VersionSharing), reader.ReadAsString() ?? string.Empty, true); break; - case RedmineKeys.STATUS: Status = (VersionStatus)Enum.Parse(typeof(VersionStatus), reader.ReadAsString() ?? string.Empty, true); break; + case RedmineKeys.SHARING: Sharing = +#if NETFRAMEWORK + (VersionSharing)Enum.Parse(typeof(VersionSharing), reader.ReadAsString() ?? string.Empty, true); break; +#else + Enum.Parse(reader.ReadAsString() ?? string.Empty, true); break; +#endif + case RedmineKeys.STATUS: Status = +#if NETFRAMEWORK + (VersionStatus)Enum.Parse(typeof(VersionStatus), reader.ReadAsString() ?? string.Empty, true); break; +#else + Enum.Parse(reader.ReadAsString() ?? string.Empty, true); break; +#endif case RedmineKeys.UPDATED_ON: UpdatedOn = reader.ReadAsDateTime(); break; case RedmineKeys.WIKI_PAGE_TITLE: WikiPageTitle = reader.ReadAsString(); break; case RedmineKeys.ESTIMATED_HOURS: EstimatedHours = (float?)reader.ReadAsDouble(); break; @@ -207,8 +224,8 @@ public override void WriteJson(JsonWriter writer) using (new JsonObject(writer, RedmineKeys.VERSION)) { writer.WriteProperty(RedmineKeys.NAME, Name); - writer.WriteProperty(RedmineKeys.STATUS, Status.ToString().ToLowerInv()); - writer.WriteProperty(RedmineKeys.SHARING, Sharing.ToString().ToLowerInv()); + writer.WriteProperty(RedmineKeys.STATUS, Status.ToLowerName()); + writer.WriteProperty(RedmineKeys.SHARING, Sharing.ToLowerName()); writer.WriteProperty(RedmineKeys.DESCRIPTION, Description); writer.WriteDateOrEmpty(RedmineKeys.DUE_DATE, DueDate); if (CustomFields != null) @@ -227,18 +244,18 @@ public override void WriteJson(JsonWriter writer) /// /// /// - public override bool Equals(Version other) + public bool Equals(Version other) { if (other == null) return false; return base.Equals(other) && Project == other.Project && string.Equals(Description, other.Description, StringComparison.Ordinal) - && Status == other.Status - && DueDate == other.DueDate - && Sharing == other.Sharing - && CreatedOn == other.CreatedOn - && UpdatedOn == other.UpdatedOn - && (CustomFields?.Equals(other.CustomFields) ?? other.CustomFields == null) + && Status == other.Status + && DueDate == other.DueDate + && Sharing == other.Sharing + && CreatedOn == other.CreatedOn + && UpdatedOn == other.UpdatedOn + && (CustomFields?.Equals(other.CustomFields) ?? other.CustomFields == null) && string.Equals(WikiPageTitle,other.WikiPageTitle, StringComparison.Ordinal) && EstimatedHours == other.EstimatedHours && SpentHours == other.SpentHours; diff --git a/src/redmine-net-api/Types/Watcher.cs b/src/redmine-net-api/Types/Watcher.cs index b990a464..a90913c2 100644 --- a/src/redmine-net-api/Types/Watcher.cs +++ b/src/redmine-net-api/Types/Watcher.cs @@ -18,6 +18,8 @@ limitations under the License. using System.Diagnostics; using System.Globalization; using System.Xml.Serialization; +using Redmine.Net.Api.Common; +using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; namespace Redmine.Net.Api.Types @@ -25,7 +27,7 @@ namespace Redmine.Net.Api.Types /// /// /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.USER)] public sealed class Watcher : IdentifiableName ,IEquatable @@ -36,7 +38,7 @@ public sealed class Watcher : IdentifiableName /// /// /// - public string Value => Id.ToString(CultureInfo.InvariantCulture); + public string Value => Id.ToInvariantString(); #endregion diff --git a/src/redmine-net-api/Types/WikiPage.cs b/src/redmine-net-api/Types/WikiPage.cs index 76125cef..2b9f35e9 100644 --- a/src/redmine-net-api/Types/WikiPage.cs +++ b/src/redmine-net-api/Types/WikiPage.cs @@ -23,13 +23,16 @@ limitations under the License. using Redmine.Net.Api.Extensions; using Redmine.Net.Api.Internals; using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; +using Redmine.Net.Api.Serialization.Json.Extensions; +using Redmine.Net.Api.Serialization.Xml.Extensions; namespace Redmine.Net.Api.Types { /// /// Availability 2.2 /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")] [XmlRoot(RedmineKeys.WIKI_PAGE)] public sealed class WikiPage : Identifiable { @@ -82,7 +85,7 @@ public sealed class WikiPage : Identifiable /// /// The attachments. /// - public IList Attachments { get; set; } + public List Attachments { get; set; } /// /// Sets the uploads. @@ -91,7 +94,7 @@ public sealed class WikiPage : Identifiable /// The uploads. /// /// Availability starting with redmine version 3.3 - public IList Uploads { get; set; } + public List Uploads { get; set; } #endregion #region Implementation of IXmlSerializable @@ -288,6 +291,5 @@ public override int GetHashCode() /// /// private string DebuggerDisplay => $"[WikiPage: Id={Id.ToInvariantString()}, Title={Title}]"; - } } \ No newline at end of file diff --git a/src/redmine-net-api/_net20/IProgress{T}.cs b/src/redmine-net-api/_net20/IProgress{T}.cs new file mode 100644 index 00000000..add86bde --- /dev/null +++ b/src/redmine-net-api/_net20/IProgress{T}.cs @@ -0,0 +1,54 @@ +#if NET20 +/* + Copyright 2011 - 2025 Adrian Popescu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +namespace System; + +/// Defines a provider for progress updates. +/// The type of progress update value. +public interface IProgress +{ + /// Reports a progress update. + /// The value of the updated progress. + void Report(T value); +} + +/// +/// +/// +/// +public sealed class Progress : IProgress +{ + private readonly Action _handler; + + /// + /// + /// + /// + public Progress(Action handler) + { + _handler = handler; + } + + /// + /// + /// + /// + public void Report(T value) + { + _handler(value); + } +} +#endif \ No newline at end of file diff --git a/src/redmine-net-api/_net20/RedmineManagerAsyncObsolete.cs b/src/redmine-net-api/_net20/RedmineManagerAsyncObsolete.cs deleted file mode 100644 index 8344efba..00000000 --- a/src/redmine-net-api/_net20/RedmineManagerAsyncObsolete.cs +++ /dev/null @@ -1,324 +0,0 @@ -#if NET20 - -/* - Copyright 2011 - 2025 Adrian Popescu - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Globalization; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Serialization; -using Redmine.Net.Api.Types; - -namespace Redmine.Net.Api.Async -{ - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public delegate void Task(); - - /// - /// - /// - /// The type of the resource. - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] public delegate TRes Task(); - - /// - /// - /// - [Obsolete(RedmineConstants.OBSOLETE_TEXT)] - public static class RedmineManagerAsync - { - /// - /// Gets the current user asynchronous. - /// - /// The redmine manager. - /// The parameters. - /// - public static Task GetCurrentUserAsync(this RedmineManager redmineManager, - NameValueCollection parameters = null) - { - return delegate { return redmineManager.GetCurrentUser(parameters); }; - } - - /// - /// Creates the or update wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// Name of the page. - /// The wiki page. - /// - public static Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId, - string pageName, WikiPage wikiPage) - { - return delegate { return redmineManager.CreateWikiPage(projectId, pageName, wikiPage); }; - } - - /// - /// - /// - /// - /// - /// - /// - /// - public static Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, - string pageName, WikiPage wikiPage) - { - return delegate { redmineManager.UpdateWikiPage(projectId, pageName, wikiPage); }; - } - - /// - /// Deletes the wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// Name of the page. - /// - public static Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName) - { - return delegate { redmineManager.DeleteWikiPage(projectId, pageName); }; - } - - /// - /// Gets the wiki page asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// The parameters. - /// Name of the page. - /// The version. - /// - public static Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId, - NameValueCollection parameters, string pageName, uint version = 0) - { - return delegate { return redmineManager.GetWikiPage(projectId, parameters, pageName, version); }; - } - - /// - /// Gets all wiki pages asynchronous. - /// - /// The redmine manager. - /// The project identifier. - /// - public static Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, string projectId) - { - return delegate { return redmineManager.GetAllWikiPages(projectId); }; - } - - /// - /// Adds the user to group asynchronous. - /// - /// The redmine manager. - /// The group identifier. - /// The user identifier. - /// - public static Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId) - { - return delegate { redmineManager.AddUserToGroup(groupId, userId); }; - } - - /// - /// Removes the user from group asynchronous. - /// - /// The redmine manager. - /// The group identifier. - /// The user identifier. - /// - public static Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId) - { - return delegate { redmineManager.RemoveUserFromGroup(groupId, userId); }; - } - - /// - /// Adds the watcher to issue asynchronous. - /// - /// The redmine manager. - /// The issue identifier. - /// The user identifier. - /// - public static Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId) - { - return delegate { redmineManager.AddWatcherToIssue(issueId, userId); }; - } - - /// - /// Removes the watcher from issue asynchronous. - /// - /// The redmine manager. - /// The issue identifier. - /// The user identifier. - /// - public static Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId) - { - return delegate { redmineManager.RemoveWatcherFromIssue(issueId, userId); }; - } - - /// - /// Gets the object asynchronous. - /// - /// - /// The redmine manager. - /// The identifier. - /// The parameters. - /// - public static Task GetObjectAsync(this RedmineManager redmineManager, string id, - NameValueCollection parameters) where T : class, new() - { - return delegate { return redmineManager.GetObject(id, parameters); }; - } - - /// - /// Creates the object asynchronous. - /// - /// - /// The redmine manager. - /// The object. - /// - public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity) where T : class, new() - { - return CreateObjectAsync(redmineManager, entity, null); - } - - /// - /// Creates the object asynchronous. - /// - /// - /// The redmine manager. - /// The object. - /// The owner identifier. - /// - public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity, string ownerId) - where T : class, new() - { - return delegate { return redmineManager.CreateObject(entity, ownerId); }; - } - - /// - /// Gets the paginated objects asynchronous. - /// - /// - /// The redmine manager. - /// The parameters. - /// - public static Task> GetPaginatedObjectsAsync(this RedmineManager redmineManager, - NameValueCollection parameters) where T : class, new() - { - return delegate { return redmineManager.GetPaginatedObjects(parameters); }; - } - - /// - /// Gets the objects asynchronous. - /// - /// - /// The redmine manager. - /// The parameters. - /// - public static Task> GetObjectsAsync(this RedmineManager redmineManager, - NameValueCollection parameters) where T : class, new() - { - return delegate { return redmineManager.GetObjects(parameters); }; - } - - /// - /// Updates the object asynchronous. - /// - /// - /// The redmine manager. - /// The identifier. - /// The object. - /// The project identifier. - /// - public static Task UpdateObjectAsync(this RedmineManager redmineManager, string id, T entity, - string projectId = null) where T : class, new() - { - return delegate { redmineManager.UpdateObject(id, entity, projectId); }; - } - - /// - /// Deletes the object asynchronous. - /// - /// - /// The redmine manager. - /// The identifier. - /// - public static Task DeleteObjectAsync(this RedmineManager redmineManager, string id) where T : class, new() - { - return delegate { redmineManager.DeleteObject(id); }; - } - - /// - /// Uploads the file asynchronous. - /// - /// The redmine manager. - /// The data. - /// - public static Task UploadFileAsync(this RedmineManager redmineManager, byte[] data) - { - return delegate { return redmineManager.UploadFile(data); }; - } - - /// - /// Downloads the file asynchronous. - /// - /// The redmine manager. - /// The address. - /// - public static Task DownloadFileAsync(this RedmineManager redmineManager, string address) - { - return delegate { return redmineManager.DownloadFile(address); }; - } - - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static Task> SearchAsync(this RedmineManager redmineManager, string q, int limit = RedmineManager.DEFAULT_PAGE_SIZE_VALUE, int offset = 0, SearchFilterBuilder searchFilter = null) - { - if (q.IsNullOrWhiteSpace()) - { - throw new ArgumentNullException(nameof(q)); - } - - var parameters = new NameValueCollection - { - {RedmineKeys.Q, q}, - {RedmineKeys.LIMIT, limit.ToString(CultureInfo.InvariantCulture)}, - {RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture)}, - }; - - if (searchFilter != null) - { - parameters = searchFilter.Build(parameters); - } - - var result = redmineManager.GetPaginatedObjectsAsync(parameters); - - return result; - } - } -} -#endif \ No newline at end of file diff --git a/src/redmine-net-api/redmine-net-api.csproj b/src/redmine-net-api/redmine-net-api.csproj index 4e1ea59f..6f6c22a4 100644 --- a/src/redmine-net-api/redmine-net-api.csproj +++ b/src/redmine-net-api/redmine-net-api.csproj @@ -1,5 +1,11 @@ + + + |net20|net40| + |net20|net40|net45|net451|net452|net46|net461| + |net45|net451|net452|net46|net461| + Redmine.Net.Api @@ -25,6 +31,21 @@ CS0618; CA1002; + + NU5105; + CA1303; + CA1056; + CA1062; + CA1707; + CA1716; + CA1724; + CA1806; + CA2227; + CS0612; + CS0618; + CA1002; + SYSLIB0014; + @@ -41,10 +62,6 @@ $(SolutionDir)/artifacts - - - - Adrian Popescu Redmine Api is a .NET rest client for Redmine. @@ -73,23 +90,35 @@ snupkg true + + + + + + + + + + + + - - - + + + + + + + - - - - redmine-net-api.snk @@ -98,7 +127,7 @@ - + <_Parameter1>Padi.DotNet.RedmineAPI.Tests diff --git a/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerCollection.cs b/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerCollection.cs index 61b0cafb..0ca114d1 100644 --- a/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerCollection.cs +++ b/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerCollection.cs @@ -1,3 +1,5 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + namespace Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; [CollectionDefinition(Constants.RedmineTestContainerCollection)] diff --git a/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerFixture.cs b/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerFixture.cs index a9564c8e..a840849b 100644 --- a/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerFixture.cs +++ b/tests/redmine-net-api.Integration.Tests/Fixtures/RedmineTestContainerFixture.cs @@ -3,8 +3,11 @@ using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Networks; using Npgsql; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; using Redmine.Net.Api; +using Redmine.Net.Api.Options; using Testcontainers.PostgreSql; +using Xunit.Abstractions; namespace Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; @@ -19,9 +22,11 @@ public class RedmineTestContainerFixture : IAsyncLifetime private const string PostgresPassword = "postgres"; private const string RedmineSqlFilePath = "TestData/init-redmine.sql"; - public const string RedmineApiKey = "029a9d38-17e8-41ae-bc8c-fbf71e193c57"; - private readonly string RedmineNetworkAlias = Guid.NewGuid().ToString(); + + private readonly ITestOutputHelper _output; + private readonly TestContainerOptions _redmineOptions; + private INetwork Network { get; set; } private PostgreSqlContainer PostgresContainer { get; set; } private IContainer RedmineContainer { get; set; } @@ -30,16 +35,31 @@ public class RedmineTestContainerFixture : IAsyncLifetime public RedmineTestContainerFixture() { - BuildContainers(); + _redmineOptions = ConfigurationHelper.GetConfiguration(); + + if (_redmineOptions.Mode != TestContainerMode.UseExisting) + { + BuildContainers(); + } } + /// + /// Detects if running in a CI/CD environment + /// + private static bool IsRunningInCiEnvironment() + { + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + + } + private void BuildContainers() { Network = new NetworkBuilder() .WithDriver(NetworkDriver.Bridge) .Build(); - - PostgresContainer = new PostgreSqlBuilder() + + var postgresBuilder = new PostgreSqlBuilder() .WithImage(PostgresImage) .WithNetwork(Network) .WithNetworkAliases(RedmineNetworkAlias) @@ -50,10 +70,20 @@ private void BuildContainers() { "POSTGRES_USER", PostgresUser }, { "POSTGRES_PASSWORD", PostgresPassword }, }) - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(PostgresPort)) - .Build(); + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(PostgresPort)); + + if (_redmineOptions.Mode == TestContainerMode.CreateNewWithRandomPorts) + { + postgresBuilder.WithPortBinding(PostgresPort, assignRandomHostPort: true); + } + else + { + postgresBuilder.WithPortBinding(PostgresPort, PostgresPort); + } + + PostgresContainer = postgresBuilder.Build(); - RedmineContainer = new ContainerBuilder() + var redmineBuilder = new ContainerBuilder() .WithImage(RedmineImage) .WithNetwork(Network) .WithPortBinding(RedminePort, assignRandomHostPort: true) @@ -66,25 +96,60 @@ private void BuildContainers() { "REDMINE_DB_PASSWORD", PostgresPassword }, }) .DependsOn(PostgresContainer) - .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request.ForPort(RedminePort).ForPath("/"))) - .Build(); + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(RedminePort).ForPath("/"))); + + if (_redmineOptions.Mode == TestContainerMode.CreateNewWithRandomPorts) + { + redmineBuilder.WithPortBinding(RedminePort, assignRandomHostPort: true); + } + else + { + redmineBuilder.WithPortBinding(RedminePort, RedminePort); + } + + RedmineContainer = redmineBuilder.Build(); } public async Task InitializeAsync() { - await Network.CreateAsync(); - - await PostgresContainer.StartAsync(); - - await RedmineContainer.StartAsync(); + var rmgBuilder = new RedmineManagerOptionsBuilder(); + + switch (_redmineOptions.AuthenticationMode) + { + case AuthenticationMode.ApiKey: + var apiKey = _redmineOptions.Authentication.ApiKey; + rmgBuilder.WithApiKeyAuthentication(apiKey); + break; + case AuthenticationMode.Basic: + var username = _redmineOptions.Authentication.Basic.Username; + var password = _redmineOptions.Authentication.Basic.Password; + rmgBuilder.WithBasicAuthentication(username, password); + break; + } + + if (_redmineOptions.Mode == TestContainerMode.UseExisting) + { + RedmineHost = _redmineOptions.Url; + } + else + { + await Network.CreateAsync(); - await SeedTestDataAsync(PostgresContainer, CancellationToken.None); + await PostgresContainer.StartAsync(); - RedmineHost = $"http://{RedmineContainer.Hostname}:{RedmineContainer.GetMappedPublicPort(RedminePort)}"; - - var rmgBuilder = new RedmineManagerOptionsBuilder() + await RedmineContainer.StartAsync(); + + await SeedTestDataAsync(PostgresContainer, CancellationToken.None); + + RedmineHost = $"http://{RedmineContainer.Hostname}:{RedmineContainer.GetMappedPublicPort(RedminePort)}"; + } + + rmgBuilder .WithHost(RedmineHost) - .WithBasicAuthentication("adminuser", "1qaz2wsx"); + .UseHttpClient() + //.UseWebClient() + .WithXmlSerialization(); RedmineManager = new RedmineManager(rmgBuilder); } @@ -93,15 +158,18 @@ public async Task DisposeAsync() { var exceptions = new List(); - await SafeDisposeAsync(() => RedmineContainer.StopAsync()); - await SafeDisposeAsync(() => PostgresContainer.StopAsync()); - await SafeDisposeAsync(() => Network.DisposeAsync().AsTask()); - - if (exceptions.Count > 0) + if (_redmineOptions.Mode != TestContainerMode.UseExisting) { - throw new AggregateException(exceptions); + await SafeDisposeAsync(() => RedmineContainer.StopAsync()); + await SafeDisposeAsync(() => PostgresContainer.StopAsync()); + await SafeDisposeAsync(() => Network.DisposeAsync().AsTask()); + + if (exceptions.Count > 0) + { + throw new AggregateException(exceptions); + } } - + return; async Task SafeDisposeAsync(Func disposeFunc) @@ -117,7 +185,7 @@ async Task SafeDisposeAsync(Func disposeFunc) } } - private static async Task SeedTestDataAsync(PostgreSqlContainer container, CancellationToken ct) + private async Task SeedTestDataAsync(PostgreSqlContainer container, CancellationToken ct) { const int maxDbAttempts = 10; var dbRetryDelay = TimeSpan.FromSeconds(2); @@ -143,7 +211,19 @@ private static async Task SeedTestDataAsync(PostgreSqlContainer container, Cance var res = await container.ExecScriptAsync(sql, ct); if (!string.IsNullOrWhiteSpace(res.Stderr)) { - // Optionally log stderr + _output.WriteLine(res.Stderr); } } -} \ No newline at end of file +} + +/// +/// Enum defining how containers should be managed +/// +public enum TestContainerMode +{ + /// Use existing running containers at specified URL + UseExisting, + + /// Create new containers with random ports (CI-friendly) + CreateNewWithRandomPorts +} diff --git a/tests/redmine-net-api.Integration.Tests/Helpers/AssertHelpers.cs b/tests/redmine-net-api.Integration.Tests/Helpers/AssertHelpers.cs new file mode 100644 index 00000000..d105f53e --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Helpers/AssertHelpers.cs @@ -0,0 +1,31 @@ +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +internal static class AssertHelpers +{ + /// + /// Asserts that two values are equal within the specified tolerance. + /// + public static void Equal(float expected, float actual, float tolerance = 1e-4f) + => Assert.InRange(actual, expected - tolerance, expected + tolerance); + + /// + /// Asserts that two values are equal within the specified tolerance. + /// + public static void Equal(decimal expected, decimal actual, decimal tolerance = 0.0001m) + => Assert.InRange(actual, expected - tolerance, expected + tolerance); + + /// + /// Asserts that two values are equal within the supplied tolerance. + /// Kind is ignored – both values are first converted to UTC. + /// + public static void Equal(DateTime expected, DateTime actual, TimeSpan? tolerance = null) + { + tolerance ??= TimeSpan.FromSeconds(1); + + var expectedUtc = expected.ToUniversalTime(); + var actualUtc = actual.ToUniversalTime(); + + Assert.InRange(actualUtc, expectedUtc - tolerance.Value, expectedUtc + tolerance.Value); + } + +} diff --git a/tests/redmine-net-api.Integration.Tests/Helpers/FileGeneratorHelper.cs b/tests/redmine-net-api.Integration.Tests/Helpers/FileGeneratorHelper.cs new file mode 100644 index 00000000..7ec2cc47 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Helpers/FileGeneratorHelper.cs @@ -0,0 +1,142 @@ +using System.Text; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; +using File = System.IO.File; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +internal static class FileGeneratorHelper +{ + private static readonly string[] Extensions = [".txt", ".doc", ".pdf", ".xml", ".json"]; + + /// + /// Generates random file content with a specified size. + /// + /// Size of the file in kilobytes. + /// Byte array containing the file content. + public static byte[] GenerateRandomFileBytes(int sizeInKb) + { + var sizeInBytes = sizeInKb * 1024; + var bytes = new byte[sizeInBytes]; + RandomHelper.FillRandomBytes(bytes); + return bytes; + } + + /// + /// Generates a random text file with a specified size. + /// + /// Size of the file in kilobytes. + /// Byte array containing the text file content. + public static byte[] GenerateRandomTextFileBytes(int sizeInKb) + { + var roughCharCount = sizeInKb * 1024; + + var sb = new StringBuilder(roughCharCount); + + while (sb.Length < roughCharCount) + { + sb.AppendLine(RandomHelper.GenerateText(RandomHelper.GetRandomNumber(5, 80))); + } + + var text = sb.ToString(); + + if (text.Length > roughCharCount) + { + text = text[..roughCharCount]; + } + + return Encoding.UTF8.GetBytes(text); + } + + /// + /// Creates a random file with a specified size and returns its path. + /// + /// Size of the file in kilobytes. + /// If true, generates text content; otherwise, generates binary content. + /// Path to the created temporary file. + public static string CreateRandomFile(int sizeInKb, bool useTextContent = true) + { + var extension = Extensions[RandomHelper.GetRandomNumber(Extensions.Length)]; + var fileName = RandomHelper.GenerateText("test-file", 7); + var filePath = Path.Combine(Path.GetTempPath(), $"{fileName}{extension}"); + + var content = useTextContent + ? GenerateRandomTextFileBytes(sizeInKb) + : GenerateRandomFileBytes(sizeInKb); + + File.WriteAllBytes(filePath, content); + return filePath; + } + +} + +internal static class FileTestHelper +{ + private static (string fileNameame, byte[] fileContent) GenerateFile(int sizeInKb) + { + var fileName = RandomHelper.GenerateText("test-file", 7); + var fileContent = sizeInKb >= 1024 + ? FileGeneratorHelper.GenerateRandomTextFileBytes(sizeInKb) + : FileGeneratorHelper.GenerateRandomFileBytes(sizeInKb); + + return (fileName, fileContent); + } + public static Upload UploadRandomFile(IRedmineManager client, int sizeInKb, RequestOptions options = null) + { + var (fileName, fileContent) = GenerateFile(sizeInKb); + return client.UploadFile(fileContent, fileName); + } + + /// + /// Helper method to upload a 500KB file. + /// + /// The Redmine API client. + /// Request options. + /// API response message containing the uploaded file information. + public static Upload UploadRandom500KbFile(IRedmineManager client, RequestOptions options = null) + { + return UploadRandomFile(client, 500, options); + } + + /// + /// Helper method to upload a 1MB file. + /// + /// The Redmine API client. + /// Request options. + /// API response message containing the uploaded file information. + public static Upload UploadRandom1MbFile(IRedmineManager client, RequestOptions options = null) + { + return UploadRandomFile(client, 1024, options); + } + + public static async Task UploadRandomFileAsync(IRedmineManagerAsync client, int sizeInKb, RequestOptions options = null) + { + var (fileName, fileContent) = GenerateFile(sizeInKb); + + return await client.UploadFileAsync(fileContent, fileName, options); + } + + /// + /// Helper method to upload a 500KB file. + /// + /// The Redmine API client. + /// Request options. + /// API response message containing the uploaded file information. + public static Task UploadRandom500KbFileAsync(IRedmineManagerAsync client, RequestOptions options = null) + { + return UploadRandomFileAsync(client, 500, options); + } + + /// + /// Helper method to upload a 1MB file. + /// + /// The Redmine API client. + /// Request options. + /// API response message containing the uploaded file information. + public static Task UploadRandom1MbFileAsync(IRedmineManagerAsync client, RequestOptions options = null) + { + return UploadRandomFileAsync(client, 1024, options); + } + +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Helpers/RandomHelper.cs b/tests/redmine-net-api.Integration.Tests/Helpers/RandomHelper.cs new file mode 100644 index 00000000..ff4923b5 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Helpers/RandomHelper.cs @@ -0,0 +1,218 @@ +using System.Text; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +internal static class RandomHelper +{ + /// + /// Generates a cryptographically strong, random string suffix. + /// This method is thread-safe as Guid.NewGuid() is thread-safe. + /// + /// A random string, 32 characters long, consisting of hexadecimal characters, without hyphens. + private static string GenerateSuffix() + { + return Guid.NewGuid().ToString("N"); + } + + private static readonly char[] EnglishAlphabetChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + .ToCharArray(); + + // ThreadLocal ensures that each thread has its own instance of Random, + // which is important because System.Random is not thread-safe for concurrent use. + // Seed with Guid for better randomness across instances + private static readonly ThreadLocal ThreadRandom = + new ThreadLocal(() => new Random(Guid.NewGuid().GetHashCode())); + + /// + /// Generates a random string of a specified length using only English alphabet characters. + /// This method is thread-safe. + /// + /// The desired length of the random string. Defaults to 10. + /// A random string composed of English alphabet characters. + private static string GenerateRandomString(int length = 10) + { + if (length <= 0) + { + throw new ArgumentOutOfRangeException(nameof(length), "Length must be a positive integer."); + } + + var random = ThreadRandom.Value; + var result = new StringBuilder(length); + for (var i = 0; i < length; i++) + { + result.Append(EnglishAlphabetChars[random.Next(EnglishAlphabetChars.Length)]); + } + + return result.ToString(); + } + + internal static void FillRandomBytes(byte[] bytes) + { + ThreadRandom.Value.NextBytes(bytes); + } + + internal static int GetRandomNumber(int max) + { + return ThreadRandom.Value.Next(max); + } + + internal static int GetRandomNumber(int min, int max) + { + return ThreadRandom.Value.Next(min, max); + } + + /// + /// Generates a random alphabetic suffix, defaulting to 10 characters. + /// This method is thread-safe. + /// + /// The desired length of the suffix. Defaults to 10. + /// A random alphabetic string. + public static string GenerateText(int length = 10) + { + return GenerateRandomString(length); + } + + /// + /// Generates a random name by combining a specified prefix and a random alphabetic suffix. + /// This method is thread-safe. + /// Example: if the prefix is "MyItem", the result could be "MyItem_aBcDeFgHiJ". + /// + /// The prefix for the name. A '_' separator will be added. + /// The desired length of the random suffix. Defaults to 10. + /// A string combining the prefix, an underscore, and a random alphabetic suffix. + /// If the prefix is null or empty, it returns just the random suffix. + public static string GenerateText(string prefix = null, int suffixLength = 10) + { + var suffix = GenerateRandomString(suffixLength); + return string.IsNullOrEmpty(prefix) ? suffix : $"{prefix}_{suffix}"; + } + + /// + /// Generates a random email address with alphabetic characters only. + /// + /// Length of the local part (before @). Defaults to 8. + /// Length of the domain name (without extension). Defaults to 6. + /// A random email address with only alphabetic characters. + public static string GenerateEmail(int localPartLength = 8, int domainLength = 6) + { + if (localPartLength <= 0 || domainLength <= 0) + { + throw new ArgumentOutOfRangeException( + localPartLength <= 0 ? nameof(localPartLength) : nameof(domainLength), + "Length must be a positive integer."); + } + + var localPart = GenerateRandomString(localPartLength); + var domain = GenerateRandomString(domainLength).ToLower(); + + // Use common TLDs + var tlds = new[] { "com", "org", "net", "io" }; + var tld = tlds[ThreadRandom.Value.Next(tlds.Length)]; + + return $"{localPart}@{domain}.{tld}"; + } + + /// + /// Generates a random webpage URL with alphabetic characters only. + /// + /// Length of the domain name (without extension). Defaults to 8. + /// Length of the path segment. Defaults to 10. + /// A random webpage URL with only alphabetic characters. + public static string GenerateWebpage(int domainLength = 8, int pathLength = 10) + { + if (domainLength <= 0 || pathLength <= 0) + { + throw new ArgumentOutOfRangeException( + domainLength <= 0 ? nameof(domainLength) : nameof(pathLength), + "Length must be a positive integer."); + } + + var domain = GenerateRandomString(domainLength).ToLower(); + + // Use common TLDs + var tlds = new[] { "com", "org", "net", "io" }; + var tld = tlds[ThreadRandom.Value.Next(tlds.Length)]; + + // Generate path segments + var segments = ThreadRandom.Value.Next(0, 3); + var path = ""; + + if (segments > 0) + { + var pathSegments = new List(segments); + for (int i = 0; i < segments; i++) + { + pathSegments.Add(GenerateRandomString(ThreadRandom.Value.Next(3, pathLength)).ToLower()); + } + + path = "/" + string.Join("/", pathSegments); + } + + return $"https://www.{domain}.{tld}{path}"; + } + + /// + /// Generates a random name composed only of alphabetic characters from the English alphabet. + /// + /// Length of the name. Defaults to 6. + /// Whether to capitalize the first letter. Defaults to true. + /// A random name with only English alphabetic characters. + public static string GenerateName(int length = 6, bool capitalize = true) + { + if (length <= 0) + { + throw new ArgumentOutOfRangeException(nameof(length), "Length must be a positive integer."); + } + + // Generate random name + var name = GenerateRandomString(length); + + if (capitalize) + { + name = char.ToUpper(name[0]) + name.Substring(1).ToLower(); + } + else + { + name = name.ToLower(); + } + + return name; + } + + /// + /// Generates a random full name composed only of alphabetic characters. + /// + /// Length of the first name. Defaults to 6. + /// Length of the last name. Defaults to 8. + /// A random full name with only alphabetic characters. + public static string GenerateFullName(int firstNameLength = 6, int lastNameLength = 8) + { + if (firstNameLength <= 0 || lastNameLength <= 0) + { + throw new ArgumentOutOfRangeException( + firstNameLength <= 0 ? nameof(firstNameLength) : nameof(lastNameLength), + "Length must be a positive integer."); + } + + // Generate random first and last names using the new alphabetic-only method + var firstName = GenerateName(firstNameLength); + var lastName = GenerateName(lastNameLength); + + return $"{firstName} {lastName}"; + } + + // Fisher-Yates shuffle algorithm + public static void Shuffle(this List list) + { + var n = list.Count; + var random = ThreadRandom.Value; + while (n > 1) + { + n--; + var k = random.Next(n + 1); + var value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Infrastructure/ConfigurationHelper.cs b/tests/redmine-net-api.Integration.Tests/Infrastructure/ConfigurationHelper.cs new file mode 100644 index 00000000..19a92aa9 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Infrastructure/ConfigurationHelper.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure +{ + internal static class ConfigurationHelper + { + private static IConfigurationRoot GetIConfigurationRoot(string outputPath) + { + // var environment = Environment.GetEnvironmentVariable("Environment"); + + return new ConfigurationBuilder() + .SetBasePath(outputPath) + .AddJsonFile("appsettings.json", optional: true) + // .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddJsonFile($"appsettings.local.json", optional: true) + // .AddUserSecrets("f8b9e946-b547-42f1-861c-f719dca00a84") + .Build(); + } + + public static TestContainerOptions GetConfiguration(string outputPath = "") + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + outputPath = Directory.GetCurrentDirectory(); + } + + var testContainerOptions = new TestContainerOptions(); + + var iConfig = GetIConfigurationRoot(outputPath); + + iConfig.GetSection("TestContainer") + .Bind(testContainerOptions); + + return testContainerOptions; + } + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Constants.cs b/tests/redmine-net-api.Integration.Tests/Infrastructure/Constants.cs similarity index 66% rename from tests/redmine-net-api.Integration.Tests/Constants.cs rename to tests/redmine-net-api.Integration.Tests/Infrastructure/Constants.cs index 93861d62..85806f08 100644 --- a/tests/redmine-net-api.Integration.Tests/Constants.cs +++ b/tests/redmine-net-api.Integration.Tests/Infrastructure/Constants.cs @@ -1,4 +1,4 @@ -namespace Padi.DotNet.RedmineAPI.Integration.Tests; +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; public static class Constants { diff --git a/tests/redmine-net-api.Integration.Tests/Infrastructure/RedmineOptions.cs b/tests/redmine-net-api.Integration.Tests/Infrastructure/RedmineOptions.cs new file mode 100644 index 00000000..2c5f5d09 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Infrastructure/RedmineOptions.cs @@ -0,0 +1,35 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure +{ + public sealed class TestContainerOptions + { + public string Url { get; set; } + + public AuthenticationMode AuthenticationMode { get; set; } + + public Authentication Authentication { get; set; } + + public TestContainerMode Mode { get; set; } + } + + public sealed class Authentication + { + public string ApiKey { get; set; } + + public BasicAuthentication Basic { get; set; } + } + + public sealed class BasicAuthentication + { + public string Username { get; set; } + public string Password { get; set; } + } + + public enum AuthenticationMode + { + None, + ApiKey, + Basic + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsAsync.cs deleted file mode 100644 index b90554ee..00000000 --- a/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsAsync.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System.Collections.Specialized; -using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; -using Redmine.Net.Api; -using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Types; -using Version = Redmine.Net.Api.Types.Version; - -namespace Padi.DotNet.RedmineAPI.Integration.Tests; - -[Collection(Constants.RedmineTestContainerCollection)] -public class RedmineIntegrationTestsAsync(RedmineTestContainerFixture fixture) -{ - private readonly RedmineManager _redmineManager = fixture.RedmineManager; - - [Fact] - public async Task Should_ReturnProjectsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnRolesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnAttachmentsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnCustomFieldsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnGroupsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnFilesAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnIssuesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task GetIssue_WithVersions_ShouldReturnAsync() - { - var issue = await _redmineManager.GetAsync(5.ToInvariantString(), - new RequestOptions { - QueryString = new NameValueCollection() - { - { RedmineKeys.INCLUDE, RedmineKeys.WATCHERS } - } - } - ); - Assert.NotNull(issue); - } - - [Fact] - public async Task Should_ReturnIssueCategoriesAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnIssueCustomFieldsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnIssuePrioritiesAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.ISSUE_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnIssueRelationsAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.ISSUE_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnIssueStatusesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnJournalsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnNewsAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnProjectMembershipsAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnQueriesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnSearchesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnTimeEntriesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnTimeEntryActivitiesAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnTrackersAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnUsersAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnVersionsAsync() - { - var list = await _redmineManager.GetAsync(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public async Task Should_ReturnWatchersAsync() - { - var list = await _redmineManager.GetAsync(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } -} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsSync.cs b/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsSync.cs deleted file mode 100644 index 024b719b..00000000 --- a/tests/redmine-net-api.Integration.Tests/RedmineIntegrationTestsSync.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System.Collections.Specialized; -using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; -using Redmine.Net.Api; -using Redmine.Net.Api.Net; -using Redmine.Net.Api.Types; -using Version = Redmine.Net.Api.Types.Version; - -namespace Padi.DotNet.RedmineAPI.Integration.Tests; - -[Collection(Constants.RedmineTestContainerCollection)] -public class RedmineIntegrationTestsSync(RedmineTestContainerFixture fixture) -{ - private readonly RedmineManager _redmineManager = fixture.RedmineManager; - - [Fact] - public void Should_ReturnProjects() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnRoles() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnAttachments() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnCustomFields() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnGroups() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnFiles() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssues() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssueCategories() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssueCustomFields() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssuePriorities() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.ISSUE_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssueRelations() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.ISSUE_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnIssueStatuses() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnJournals() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnNews() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnProjectMemberships() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnQueries() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnSearches() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnTimeEntries() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnTimeEntryActivities() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnTrackers() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnUsers() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnVersions() - { - var list = _redmineManager.Get(new RequestOptions() - { - QueryString = new NameValueCollection() - { - { RedmineKeys.PROJECT_ID, 1.ToString() } - } - }); - Assert.NotNull(list); - Assert.NotEmpty(list); - } - - [Fact] - public void Should_ReturnWatchers() - { - var list = _redmineManager.Get(); - Assert.NotNull(list); - Assert.NotEmpty(list); - } -} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/TestData/init-redmine.sql b/tests/redmine-net-api.Integration.Tests/TestData/init-redmine.sql index d511ec2b..85fabbf1 100644 --- a/tests/redmine-net-api.Integration.Tests/TestData/init-redmine.sql +++ b/tests/redmine-net-api.Integration.Tests/TestData/init-redmine.sql @@ -53,6 +53,7 @@ values (1, 'Bug', 1, false, 0, 1, null), insert into projects (id, name, description, homepage, is_public, parent_id, created_on, updated_on, identifier, status, lft, rgt, inherit_members, default_version_id, default_assigned_to_id, default_issue_query_id) values (1, 'Project-Test', null, '', true, null, '2024-09-02 10:14:33.789394', '2024-09-02 10:14:33.789394', 'project-test', 1, 1, 2, false, null, null, null); +insert into public.wikis (id, project_id, start_page, status) values (1, 1, 'Wiki', 1); insert into versions (id, project_id, name, description, effective_date, created_on, updated_on, wiki_page_title, status, sharing) values (1, 1, 'version1', '', null, '2025-04-28 17:56:49.245993', '2025-04-28 17:56:49.245993', '', 'open', 'none'), @@ -65,3 +66,5 @@ values (5, 1, 1, '#380', '', null, 1, 1, null, 2, 2, 90, 1, '2025-04-28 17:58:4 insert into watchers (id, watchable_type, watchable_id, user_id) values (8, 'Issue', 5, 90), (9, 'Issue', 5, 91); + + diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Common/EmailNotificationType.cs b/tests/redmine-net-api.Integration.Tests/Tests/Common/EmailNotificationType.cs new file mode 100644 index 00000000..2c046dad --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Common/EmailNotificationType.cs @@ -0,0 +1,29 @@ +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +public sealed record EmailNotificationType +{ + public static readonly EmailNotificationType OnlyMyEvents = new EmailNotificationType(1, "only_my_events"); + public static readonly EmailNotificationType OnlyAssigned = new EmailNotificationType(2, "only_assigned"); + public static readonly EmailNotificationType OnlyOwner = new EmailNotificationType(3, "only_owner"); + public static readonly EmailNotificationType None = new EmailNotificationType(0, ""); + + public int Id { get; } + public string Name { get; } + + private EmailNotificationType(int id, string name) + { + Id = id; + Name = name; + } + + public static EmailNotificationType FromId(int id) + { + return id switch + { + 1 => OnlyMyEvents, + 2 => OnlyAssigned, + 3 => OnlyOwner, + _ => None + }; + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Common/IssueTestHelper.cs b/tests/redmine-net-api.Integration.Tests/Tests/Common/IssueTestHelper.cs new file mode 100644 index 00000000..1be46ec3 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Common/IssueTestHelper.cs @@ -0,0 +1,82 @@ +using Redmine.Net.Api; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +internal static class IssueTestHelper +{ + internal static void AssertBasic(Issue expected, Issue actual) + { + Assert.NotNull(actual); + Assert.True(actual.Id > 0); + Assert.Equal(expected.Subject, actual.Subject); + Assert.Equal(expected.Description, actual.Description); + Assert.Equal(expected.Project.Id, actual.Project.Id); + Assert.Equal(expected.Tracker.Id, actual.Tracker.Id); + Assert.Equal(expected.Status.Id, actual.Status.Id); + Assert.Equal(expected.Priority.Id, actual.Priority.Id); + } + + internal static (Issue, Issue payload) CreateRandomIssue(RedmineManager redmineManager, int projectId = TestConstants.Projects.DefaultProjectId, + int trackerId = 1, + int priorityId = 2, + int statusId = 1, + string subject = null, + List customFields = null, + List watchers = null, + List uploads = null) + { + var issuePayload = TestEntityFactory.CreateRandomIssuePayload(projectId, trackerId, priorityId, statusId, + subject, customFields, watchers, uploads); + var issue = redmineManager.Create(issuePayload); + Assert.NotNull(issue); + return (issue, issuePayload); + } + + internal static async Task<(Issue, Issue payload)> CreateRandomIssueAsync(RedmineManager redmineManager, int projectId = TestConstants.Projects.DefaultProjectId, + int trackerId = 1, + int priorityId = 2, + int statusId = 1, + string subject = null, + List customFields = null, + List watchers = null, + List uploads = null) + { + var issuePayload = TestEntityFactory.CreateRandomIssuePayload(projectId, trackerId, priorityId, statusId, + subject, customFields, watchers, uploads); + var issue = await redmineManager.CreateAsync(issuePayload); + Assert.NotNull(issue); + return (issue, issuePayload); + } + + public static (Issue first, Issue second) CreateRandomTwoIssues(RedmineManager redmineManager) + { + return (Build(), Build()); + + Issue Build() => redmineManager.Create(TestEntityFactory.CreateRandomIssuePayload()); + } + + public static (IssueRelation issueRelation, Issue firstIssue, Issue secondIssue) CreateRandomIssueRelation(RedmineManager redmineManager, IssueRelationType issueRelationType = IssueRelationType.Relates) + { + var (i1, i2) = CreateRandomTwoIssues(redmineManager); + var rel = TestEntityFactory.CreateRandomIssueRelationPayload(i1.Id, i2.Id, issueRelationType); + var relation = redmineManager.Create(rel, i1.Id.ToString()); + return (relation, i1, i2); + } + + public static async Task<(Issue first, Issue second)> CreateRandomTwoIssuesAsync(RedmineManager redmineManager) + { + return (await BuildAsync(), await BuildAsync()); + + async Task BuildAsync() => await redmineManager.CreateAsync(TestEntityFactory.CreateRandomIssuePayload()); + } + + public static async Task<(IssueRelation issueRelation, Issue firstIssue, Issue secondIssue)> CreateRandomIssueRelationAsync(RedmineManager redmineManager, IssueRelationType issueRelationType = IssueRelationType.Relates) + { + var (i1, i2) = await CreateRandomTwoIssuesAsync(redmineManager); + var rel = TestEntityFactory.CreateRandomIssueRelationPayload(i1.Id, i2.Id, issueRelationType); + var relation = redmineManager.Create(rel, i1.Id.ToString()); + return (relation, i1, i2); + } + +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Common/TestConstants.cs b/tests/redmine-net-api.Integration.Tests/Tests/Common/TestConstants.cs new file mode 100644 index 00000000..bf16727b --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Common/TestConstants.cs @@ -0,0 +1,20 @@ +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +public static class TestConstants +{ + public static class Projects + { + public const int DefaultProjectId = 1; + public const string DefaultProjectIdentifier = "1"; + public static readonly IdentifiableName DefaultProject = DefaultProject.ToIdentifiableName(); + } + + public static class Users + { + public const string DefaultPassword = "password123"; + } + +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Common/TestEntityFactory.cs b/tests/redmine-net-api.Integration.Tests/Tests/Common/TestEntityFactory.cs new file mode 100644 index 00000000..bb721f70 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Common/TestEntityFactory.cs @@ -0,0 +1,161 @@ +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; + +public static class TestEntityFactory +{ + public static Issue CreateRandomIssuePayload( + int projectId = TestConstants.Projects.DefaultProjectId, + int trackerId = 1, + int priorityId = 2, + int statusId = 1, + string subject = null, + List customFields = null, + List watchers = null, + List uploads = null) + => new() + { + Project = projectId.ToIdentifier(), + Subject = subject ?? RandomHelper.GenerateText(9), + Description = RandomHelper.GenerateText(18), + Tracker = trackerId.ToIdentifier(), + Status = statusId.ToIssueStatusIdentifier(), + Priority = priorityId.ToIdentifier(), + CustomFields = customFields, + Watchers = watchers, + Uploads = uploads + }; + + public static User CreateRandomUserPayload(UserStatus status = UserStatus.StatusActive, int? authenticationModeId = null, + EmailNotificationType emailNotificationType = null) + { + var user = new Redmine.Net.Api.Types.User + { + Login = RandomHelper.GenerateText(12), + FirstName = RandomHelper.GenerateText(8), + LastName = RandomHelper.GenerateText(10), + Email = RandomHelper.GenerateEmail(), + Password = TestConstants.Users.DefaultPassword, + AuthenticationModeId = authenticationModeId, + MailNotification = emailNotificationType?.Name, + MustChangePassword = false, + Status = status, + }; + + return user; + } + + public static Group CreateRandomGroupPayload(string name = null, List userIds = null) + { + var group = new Redmine.Net.Api.Types.Group(name ?? RandomHelper.GenerateText(9)); + if (userIds == null || userIds.Count == 0) + { + return group; + } + foreach (var userId in userIds) + { + group.Users = [IdentifiableName.Create(userId)]; + } + return group; + } + + public static Group CreateRandomGroupPayload(string name = null, List userGroups = null) + { + var group = new Redmine.Net.Api.Types.Group(name ?? RandomHelper.GenerateText(9)); + if (userGroups == null || userGroups.Count == 0) + { + return group; + } + + group.Users = userGroups; + return group; + } + + public static (string pageName, WikiPage wikiPage) CreateRandomWikiPagePayload(string pageName = null, int version = 0, List uploads = null) + { + pageName = (pageName ?? RandomHelper.GenerateText(8)); + if (char.IsLower(pageName[0])) + { + pageName = char.ToUpper(pageName[0]) + pageName[1..]; + } + var wikiPage = new WikiPage + { + Text = RandomHelper.GenerateText(10), + Comments = RandomHelper.GenerateText(15), + Version = version, + Uploads = uploads, + }; + + return (pageName, wikiPage); + } + + public static Redmine.Net.Api.Types.Version CreateRandomVersionPayload(string name = null, + VersionStatus status = VersionStatus.Open, + VersionSharing sharing = VersionSharing.None, + int dueDateDays = 30, + string wikiPageName = null, + float? estimatedHours = null, + float? spentHours = null) + { + var version = new Redmine.Net.Api.Types.Version + { + Name = name ?? RandomHelper.GenerateText(10), + Description = RandomHelper.GenerateText(15), + Status = status, + Sharing = sharing, + DueDate = DateTime.Now.Date.AddDays(dueDateDays), + EstimatedHours = estimatedHours, + SpentHours = spentHours, + WikiPageTitle = wikiPageName, + }; + + return version; + } + + public static Redmine.Net.Api.Types.News CreateRandomNewsPayload(string title = null, List uploads = null) + { + return new Redmine.Net.Api.Types.News() + { + Title = title ?? RandomHelper.GenerateText(5), + Summary = RandomHelper.GenerateText(10), + Description = RandomHelper.GenerateText(20), + Uploads = uploads + }; + } + + public static IssueCustomField CreateRandomIssueCustomFieldWithMultipleValuesPayload() + { + return IssueCustomField.CreateMultiple(1, RandomHelper.GenerateText(8), + [RandomHelper.GenerateText(4), RandomHelper.GenerateText(4)]); + } + + public static IssueCustomField CreateRandomIssueCustomFieldWithSingleValuePayload() + { + return IssueCustomField.CreateSingle(1, RandomHelper.GenerateText(8), RandomHelper.GenerateText(4)); + } + + public static IssueRelation CreateRandomIssueRelationPayload(int issueId, int issueToId, IssueRelationType issueRelationType = IssueRelationType.Relates) + { + return new IssueRelation { IssueId = issueId, IssueToId = issueToId, Type = issueRelationType };; + } + + public static Redmine.Net.Api.Types.TimeEntry CreateRandomTimeEntryPayload(int projectId, int issueId, DateTime? spentOn = null, decimal hours = 1.5m, int? activityId = null) + { + var timeEntry = new Redmine.Net.Api.Types.TimeEntry + { + Project = projectId.ToIdentifier(), + Issue = issueId.ToIdentifier(), + SpentOn = spentOn ?? DateTime.Now.Date, + Hours = hours, + Comments = RandomHelper.GenerateText(10), + }; + + if (activityId != null) + { + timeEntry.Activity = activityId.Value.ToIdentifier(); + } + + return timeEntry; + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTests.cs new file mode 100644 index 00000000..d0b7e133 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTests.cs @@ -0,0 +1,38 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Attachment; + +[Collection(Constants.RedmineTestContainerCollection)] +public class AttachmentTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void Attachment_UploadToIssue_Should_Succeed() + { + // Arrange + var upload = FileTestHelper.UploadRandom500KbFile(fixture.RedmineManager); + Assert.NotNull(upload); + Assert.NotEmpty(upload.Token); + + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager,uploads: [upload]); + Assert.NotNull(issue); + + // Act + var retrievedIssue = fixture.RedmineManager.Get( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + var attachment = retrievedIssue.Attachments.FirstOrDefault(); + Assert.NotNull(attachment); + + var downloadedAttachment = fixture.RedmineManager.Get(attachment.Id.ToString()); + + // Assert + Assert.NotNull(downloadedAttachment); + Assert.Equal(attachment.Id, downloadedAttachment.Id); + Assert.Equal(attachment.FileName, downloadedAttachment.FileName); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTestsAsync.cs new file mode 100644 index 00000000..e5bc284b --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Attachment/AttachmentTestsAsync.cs @@ -0,0 +1,74 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Attachment; + +[Collection(Constants.RedmineTestContainerCollection)] +public class AttachmentTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task Attachment_GetIssueWithAttachments_Should_Succeed() + { + // Arrange + var upload = await FileTestHelper.UploadRandom500KbFileAsync(fixture.RedmineManager); + var (issue, _) = await IssueTestHelper.CreateRandomIssueAsync(fixture.RedmineManager, uploads: [upload]); + + // Act + var retrievedIssue = await fixture.RedmineManager.GetAsync( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + // Assert + Assert.NotNull(retrievedIssue); + Assert.NotNull(retrievedIssue.Attachments); + Assert.NotEmpty(retrievedIssue.Attachments); + } + + [Fact] + public async Task Attachment_GetByIssueId_Should_Succeed() + { + // Arrange + var upload = await FileTestHelper.UploadRandom500KbFileAsync(fixture.RedmineManager); + var (issue, _) = await IssueTestHelper.CreateRandomIssueAsync(fixture.RedmineManager, uploads: [upload]); + + var retrievedIssue = await fixture.RedmineManager.GetAsync( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + var attachment = retrievedIssue.Attachments.FirstOrDefault(); + Assert.NotNull(attachment); + + // Act + var downloadedAttachment = await fixture.RedmineManager.GetAsync(attachment.Id.ToString()); + + // Assert + Assert.NotNull(downloadedAttachment); + Assert.Equal(attachment.Id, downloadedAttachment.Id); + Assert.Equal(attachment.FileName, downloadedAttachment.FileName); + } + + [Fact] + public async Task Attachment_Upload_MultipleFiles_Should_Succeed() + { + // Arrange & Act + var upload1 = await FileTestHelper.UploadRandom500KbFileAsync(fixture.RedmineManager); + Assert.NotNull(upload1); + Assert.NotEmpty(upload1.Token); + + var upload2 = await FileTestHelper.UploadRandom500KbFileAsync(fixture.RedmineManager); + Assert.NotNull(upload2); + Assert.NotEmpty(upload2.Token); + + // Assert + var (issue, _) = await IssueTestHelper.CreateRandomIssueAsync(fixture.RedmineManager, uploads: [upload1, upload2]); + + var retrievedIssue = await fixture.RedmineManager.GetAsync( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + Assert.Equal(2, retrievedIssue.Attachments.Count); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTests.cs new file mode 100644 index 00000000..9f64cdd4 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTests.cs @@ -0,0 +1,18 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.CustomField; + +[Collection(Constants.RedmineTestContainerCollection)] +public class CustomFieldTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetAllCustomFields_Should_Return_Null() + { + // Act + var customFields = fixture.RedmineManager.Get(); + + // Assert + Assert.Null(customFields); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTestsAsync.cs new file mode 100644 index 00000000..684882b7 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/CustomField/CustomFieldTestsAsync.cs @@ -0,0 +1,18 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.CustomField; + +[Collection(Constants.RedmineTestContainerCollection)] +public class CustomFieldTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllCustomFields_Should_Return_Null() + { + // Act + var customFields = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.Null(customFields); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTests.cs new file mode 100644 index 00000000..5436dbce --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTests.cs @@ -0,0 +1,18 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Enumeration; + +[Collection(Constants.RedmineTestContainerCollection)] +public class EnumerationTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetDocumentCategories_Should_Succeed() => Assert.NotNull(fixture.RedmineManager.Get()); + + [Fact] + public void GetIssuePriorities_Should_Succeed() => Assert.NotNull(fixture.RedmineManager.Get()); + + [Fact] + public void GetTimeEntryActivities_Should_Succeed() => Assert.NotNull(fixture.RedmineManager.Get()); +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTestsAsync.cs new file mode 100644 index 00000000..4731d5a0 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Enumeration/EnumerationTestsAsync.cs @@ -0,0 +1,39 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Enumeration; + +[Collection(Constants.RedmineTestContainerCollection)] +public class EnumerationTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetDocumentCategories_Should_Succeed() + { + // Act + var categories = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(categories); + } + + [Fact] + public async Task GetIssuePriorities_Should_Succeed() + { + // Act + var issuePriorities = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(issuePriorities); + } + + [Fact] + public async Task GetTimeEntryActivities_Should_Succeed() + { + // Act + var activities = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(activities); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTests.cs new file mode 100644 index 00000000..010677f9 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTests.cs @@ -0,0 +1,102 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.File; + +[Collection(Constants.RedmineTestContainerCollection)] +public class FileTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void CreateFile_Should_Succeed() + { + var (_, token) = UploadFile(); + + var filePayload = new Redmine.Net.Api.Types.File { Token = token }; + + var createdFile = fixture.RedmineManager.Create(filePayload, TestConstants.Projects.DefaultProjectIdentifier); + Assert.Null(createdFile); // the API returns null on success when no extra fields were provided + + var files = fixture.RedmineManager.GetProjectFiles(TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(files); + Assert.NotEmpty(files.Items); + } + + [Fact] + public void CreateFile_Without_Token_Should_Fail() + { + Assert.ThrowsAny(() => + fixture.RedmineManager.Create(new Redmine.Net.Api.Types.File { Filename = "project_file.zip" }, TestConstants.Projects.DefaultProjectIdentifier)); + } + + [Fact] + public void CreateFile_With_OptionalParameters_Should_Succeed() + { + var (fileName, token) = UploadFile(); + + var filePayload = new Redmine.Net.Api.Types.File + { + Token = token, + Filename = fileName, + Description = RandomHelper.GenerateText(9), + ContentType = "text/plain", + }; + + _ = fixture.RedmineManager.Create(filePayload, TestConstants.Projects.DefaultProjectIdentifier); + + var files = fixture.RedmineManager.GetProjectFiles(TestConstants.Projects.DefaultProjectIdentifier); + + var file = files.Items.FirstOrDefault(x => x.Filename == fileName); + + Assert.NotNull(file); + Assert.True(file.Id > 0); + Assert.NotEmpty(file.Digest); + Assert.Equal(filePayload.Description, file.Description); + Assert.Equal(filePayload.ContentType, file.ContentType); + Assert.EndsWith($"/attachments/download/{file.Id}/{fileName}", file.ContentUrl); + Assert.Equal(filePayload.Version, file.Version); + } + + [Fact] + public void CreateFile_With_Version_Should_Succeed() + { + var (fileName, token) = UploadFile(); + + var filePayload = new Redmine.Net.Api.Types.File + { + Token = token, + Filename = fileName, + Description = RandomHelper.GenerateText(9), + ContentType = "text/plain", + Version = 1.ToIdentifier(), + }; + + _ = fixture.RedmineManager.Create(filePayload, TestConstants.Projects.DefaultProjectIdentifier); + + var files = fixture.RedmineManager.GetProjectFiles(TestConstants.Projects.DefaultProjectIdentifier); + var file = files.Items.FirstOrDefault(x => x.Filename == fileName); + + Assert.NotNull(file); + Assert.True(file.Id > 0); + Assert.NotEmpty(file.Digest); + Assert.Equal(filePayload.Description, file.Description); + Assert.Equal(filePayload.ContentType, file.ContentType); + Assert.EndsWith($"/attachments/download/{file.Id}/{fileName}", file.ContentUrl); + Assert.Equal(filePayload.Version.Id, file.Version.Id); + } + + private (string fileName, string token) UploadFile() + { + var bytes = "Hello World!"u8.ToArray(); + var fileName = $"{RandomHelper.GenerateText(5)}.txt"; + var upload = fixture.RedmineManager.UploadFile(bytes, fileName); + + Assert.NotNull(upload); + Assert.NotNull(upload.Token); + + return (fileName, upload.Token); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTestsAsync.cs new file mode 100644 index 00000000..5363e416 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/File/FileTestsAsync.cs @@ -0,0 +1,120 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.File; + +[Collection(Constants.RedmineTestContainerCollection)] +public class FileTestsAsync(RedmineTestContainerFixture fixture) +{ + private const string PROJECT_ID = TestConstants.Projects.DefaultProjectIdentifier; + + [Fact] + public async Task CreateFile_Should_Succeed() + { + var (_, token) = await UploadFileAsync(); + + var filePayload = new Redmine.Net.Api.Types.File + { + Token = token, + }; + + var createdFile = await fixture.RedmineManager.CreateAsync(filePayload, PROJECT_ID); + Assert.Null(createdFile); + + var files = await fixture.RedmineManager.GetProjectFilesAsync(PROJECT_ID, + new RequestOptions(){ QueryString = RedmineKeys.LIMIT.WithInt(1)}); + + //Assert + Assert.NotNull(files); + Assert.NotEmpty(files.Items); + } + + [Fact] + public async Task CreateFile_Without_Token_Should_Fail() + { + await Assert.ThrowsAsync(() => fixture.RedmineManager.CreateAsync( + new Redmine.Net.Api.Types.File { Filename = "VBpMc.txt" }, PROJECT_ID)); + } + + [Fact] + public async Task CreateFile_With_OptionalParameters_Should_Succeed() + { + var (fileName, token) = await UploadFileAsync(); + + var filePayload = new Redmine.Net.Api.Types.File + { + Token = token, + Filename = fileName, + Description = RandomHelper.GenerateText(9), + ContentType = "text/plain", + }; + + _ = await fixture.RedmineManager.CreateAsync(filePayload, PROJECT_ID); + var files = await fixture.RedmineManager.GetProjectFilesAsync(PROJECT_ID); + var file = files.Items.FirstOrDefault(x => x.Filename == fileName); + + Assert.NotNull(file); + Assert.True(file.Id > 0); + Assert.NotEmpty(file.Digest); + Assert.Equal(filePayload.Description, file.Description); + Assert.Equal(filePayload.ContentType, file.ContentType); + Assert.EndsWith($"/attachments/download/{file.Id}/{fileName}", file.ContentUrl); + Assert.Equal(filePayload.Version, file.Version); + } + + [Fact] + public async Task CreateFile_With_Version_Should_Succeed() + { + var (fileName, token) = await UploadFileAsync(); + + var filePayload = new Redmine.Net.Api.Types.File + { + Token = token, + Filename = fileName, + Description = RandomHelper.GenerateText(9), + ContentType = "text/plain", + Version = 1.ToIdentifier(), + }; + + _ = await fixture.RedmineManager.CreateAsync(filePayload, PROJECT_ID); + var files = await fixture.RedmineManager.GetProjectFilesAsync(PROJECT_ID); + var file = files.Items.FirstOrDefault(x => x.Filename == fileName); + + Assert.NotNull(file); + Assert.True(file.Id > 0); + Assert.NotEmpty(file.Digest); + Assert.Equal(filePayload.Description, file.Description); + Assert.Equal(filePayload.ContentType, file.ContentType); + Assert.EndsWith($"/attachments/download/{file.Id}/{fileName}", file.ContentUrl); + Assert.Equal(filePayload.Version.Id, file.Version.Id); + } + + [Fact] + public async Task File_UploadLargeFile_Should_Succeed() + { + // Arrange & Act + var upload = await FileTestHelper.UploadRandom1MbFileAsync(fixture.RedmineManager); + + // Assert + Assert.NotNull(upload); + Assert.NotEmpty(upload.Token); + } + + private async Task<(string,string)> UploadFileAsync() + { + var bytes = "Hello World!"u8.ToArray(); + var fileName = $"{RandomHelper.GenerateText(5)}.txt"; + var upload = await fixture.RedmineManager.UploadFileAsync(bytes, fileName); + + Assert.NotNull(upload); + Assert.NotNull(upload.Token); + + return (fileName, upload.Token); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTests.cs new file mode 100644 index 00000000..d2a4c4d1 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTests.cs @@ -0,0 +1,111 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Group; + +[Collection(Constants.RedmineTestContainerCollection)] +public class GroupTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetAllGroups_Should_Succeed() + { + var groups = fixture.RedmineManager.Get(); + + Assert.NotNull(groups); + } + + [Fact] + public void CreateGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + Assert.NotNull(group); + Assert.True(group.Id > 0); + Assert.Equal(group.Name, group.Name); + } + + [Fact] + public void GetGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + var retrievedGroup = fixture.RedmineManager.Get(group.Id.ToInvariantString()); + + Assert.NotNull(retrievedGroup); + Assert.Equal(group.Id, retrievedGroup.Id); + Assert.Equal(group.Name, retrievedGroup.Name); + } + + [Fact] + public void UpdateGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + group.Name = RandomHelper.GenerateText(7); + + fixture.RedmineManager.Update(group.Id.ToInvariantString(), group); + var retrievedGroup = fixture.RedmineManager.Get(group.Id.ToInvariantString()); + + Assert.NotNull(retrievedGroup); + Assert.Equal(group.Id, retrievedGroup.Id); + Assert.Equal(group.Name, retrievedGroup.Name); + } + + [Fact] + public void DeleteGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + var groupId = group.Id.ToInvariantString(); + + fixture.RedmineManager.Delete(groupId); + + Assert.Throws(() => + fixture.RedmineManager.Get(groupId)); + } + + [Fact] + public void AddUserToGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + var userId = 1; + + fixture.RedmineManager.AddUserToGroup(group.Id, userId); + var updatedGroup = fixture.RedmineManager.Get(group.Id.ToString(), RequestOptions.Include(RedmineKeys.USERS)); + + Assert.NotNull(updatedGroup); + Assert.NotNull(updatedGroup.Users); + Assert.Contains(updatedGroup.Users, u => u.Id == userId); + } + + [Fact] + public void RemoveUserFromGroup_Should_Succeed() + { + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = fixture.RedmineManager.Create(groupPayload); + Assert.NotNull(group); + + fixture.RedmineManager.AddUserToGroup(group.Id, userId: 1); + + fixture.RedmineManager.RemoveUserFromGroup(group.Id, userId: 1); + var updatedGroup = fixture.RedmineManager.Get(group.Id.ToString(), RequestOptions.Include(RedmineKeys.USERS)); + + Assert.NotNull(updatedGroup); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTestsAsync.cs new file mode 100644 index 00000000..d67a2e0c --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Group/GroupTestsAsync.cs @@ -0,0 +1,129 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Group; + +[Collection(Constants.RedmineTestContainerCollection)] +public class GroupTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllGroups_Should_Succeed() + { + // Act + var groups = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(groups); + } + + [Fact] + public async Task CreateGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + + // Act + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + + // Assert + Assert.NotNull(group); + Assert.True(group.Id > 0); + Assert.Equal(groupPayload.Name, group.Name); + } + + [Fact] + public async Task GetGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + // Act + var retrievedGroup = await fixture.RedmineManager.GetAsync(group.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedGroup); + Assert.Equal(group.Id, retrievedGroup.Id); + Assert.Equal(group.Name, retrievedGroup.Name); + } + + [Fact] + public async Task UpdateGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + group.Name = RandomHelper.GenerateText(7); + + // Act + await fixture.RedmineManager.UpdateAsync(group.Id.ToInvariantString(), group); + var retrievedGroup = await fixture.RedmineManager.GetAsync(group.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedGroup); + Assert.Equal(group.Id, retrievedGroup.Id); + Assert.Equal(group.Name, retrievedGroup.Name); + } + + [Fact] + public async Task DeleteGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + var groupId = group.Id.ToInvariantString(); + + // Act + await fixture.RedmineManager.DeleteAsync(groupId); + + // Assert + await Assert.ThrowsAsync(async () => + await fixture.RedmineManager.GetAsync(groupId)); + } + + [Fact] + public async Task AddUserToGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + // Act + await fixture.RedmineManager.AddUserToGroupAsync(group.Id, userId: 1); + var updatedGroup = await fixture.RedmineManager.GetAsync(group.Id.ToString(), RequestOptions.Include(RedmineKeys.USERS)); + + // Assert + Assert.NotNull(updatedGroup); + Assert.NotNull(updatedGroup.Users); + Assert.Contains(updatedGroup.Users, ug => ug.Id == 1); + } + + [Fact] + public async Task RemoveUserFromGroup_Should_Succeed() + { + // Arrange + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + await fixture.RedmineManager.AddUserToGroupAsync(group.Id, userId: 1); + + // Act + await fixture.RedmineManager.RemoveUserFromGroupAsync(group.Id, userId: 1); + var updatedGroup = await fixture.RedmineManager.GetAsync(group.Id.ToString(), RequestOptions.Include(RedmineKeys.USERS)); + + // Assert + Assert.NotNull(updatedGroup); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTests.cs new file mode 100644 index 00000000..527a5135 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTests.cs @@ -0,0 +1,42 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueAttachmentTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void UploadAttachmentAndAttachToIssue_Should_Succeed() + { + // Arrange + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + + var content = "Test attachment content"u8.ToArray(); + var fileName = "test_attachment.txt"; + var upload = fixture.RedmineManager.UploadFile(content, fileName); + Assert.NotNull(upload); + Assert.NotEmpty(upload.Token); + + // Act + var updateIssue = new Redmine.Net.Api.Types.Issue + { + Subject = $"Test issue for attachment {RandomHelper.GenerateText(5)}", + Uploads = [upload] + }; + fixture.RedmineManager.Update(issue.Id.ToString(), updateIssue); + + + var retrievedIssue = fixture.RedmineManager.Get( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + // Assert + Assert.NotNull(retrievedIssue); + Assert.NotEmpty(retrievedIssue.Attachments); + Assert.Contains(retrievedIssue.Attachments, a => a.FileName == fileName); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTestsAsync.cs new file mode 100644 index 00000000..be0dc85e --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueAttachmentUploadTestsAsync.cs @@ -0,0 +1,44 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueAttachmentTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task UploadAttachmentAndAttachToIssue_Should_Succeed() + { + // Arrange + var (issue, _) = await IssueTestHelper.CreateRandomIssueAsync(fixture.RedmineManager); + + var fileContent = "Test attachment content"u8.ToArray(); + var filename = "test_attachment.txt"; + var upload = await fixture.RedmineManager.UploadFileAsync(fileContent, filename); + Assert.NotNull(upload); + Assert.NotEmpty(upload.Token); + + // Prepare issue with attachment + var updateIssue = new Redmine.Net.Api.Types.Issue + { + Subject = $"Test issue for attachment {RandomHelper.GenerateText(5)}", + Uploads = [upload] + }; + + // Act + await fixture.RedmineManager.UpdateAsync(issue.Id.ToString(), updateIssue); + + var retrievedIssue = + await fixture.RedmineManager.GetAsync(issue.Id.ToString(), RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + // Assert + Assert.NotNull(retrievedIssue); + Assert.NotNull(retrievedIssue.Attachments); + Assert.NotEmpty(retrievedIssue.Attachments); + Assert.Contains(retrievedIssue.Attachments, a => a.FileName == filename); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTests.cs new file mode 100644 index 00000000..2c1fb974 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTests.cs @@ -0,0 +1,132 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void CreateIssue_Should_Succeed() + { + //Arrange + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + + // Assert + Assert.NotNull(issue); + Assert.True(issue.Id > 0); + } + + [Fact] + public void CreateIssue_With_IssueCustomField_Should_Succeed() + { + //Arrange + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager, customFields: + [ + TestEntityFactory.CreateRandomIssueCustomFieldWithSingleValuePayload() + ]); + + // Assert + Assert.NotNull(issue); + Assert.True(issue.Id > 0); + } + + [Fact] + public void GetIssue_Should_Succeed() + { + //Arrange + var (issue, issuePayload) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + + Assert.NotNull(issue); + Assert.True(issue.Id > 0); + + var issueId = issue.Id.ToInvariantString(); + + //Act + var retrievedIssue = fixture.RedmineManager.Get(issueId); + + //Assert + IssueTestHelper.AssertBasic(issuePayload, retrievedIssue); + } + + [Fact] + public void UpdateIssue_Should_Succeed() + { + //Arrange + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + + issue.Subject = RandomHelper.GenerateText(9); + issue.Description = RandomHelper.GenerateText(18); + issue.Status = 2.ToIssueStatusIdentifier(); + issue.Notes = RandomHelper.GenerateText("Note"); + + var issueId = issue.Id.ToInvariantString(); + + //Act + fixture.RedmineManager.Update(issueId, issue); + var updatedIssue = fixture.RedmineManager.Get(issueId); + + //Assert + IssueTestHelper.AssertBasic(issue, updatedIssue); + Assert.Equal(issue.Subject, updatedIssue.Subject); + Assert.Equal(issue.Description, updatedIssue.Description); + Assert.Equal(issue.Status.Id, updatedIssue.Status.Id); + } + + [Fact] + public void DeleteIssue_Should_Succeed() + { + //Arrange + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + + var issueId = issue.Id.ToInvariantString(); + + //Act + fixture.RedmineManager.Delete(issueId); + + //Assert + Assert.Throws(() => fixture.RedmineManager.Get(issueId)); + } + + [Fact] + public void GetIssue_With_Watchers_And_Relations_Should_Succeed() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var createdUser = fixture.RedmineManager.Create(userPayload); + Assert.NotNull(createdUser); + + var userId = createdUser.Id; + + var (firstIssue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager, customFields: + [ + IssueCustomField.CreateMultiple(1, RandomHelper.GenerateText(8), + [RandomHelper.GenerateText(4), RandomHelper.GenerateText(4)]) + ], watchers: + [new Watcher() { Id = 1 }, new Watcher() { Id = userId }]); + + var (secondIssue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager, + customFields: [TestEntityFactory.CreateRandomIssueCustomFieldWithMultipleValuesPayload()], + watchers: [new Watcher() { Id = 1 }, new Watcher() { Id = userId }]); + + var issueRelation = new IssueRelation() + { + Type = IssueRelationType.Relates, + IssueToId = firstIssue.Id, + }; + _ = fixture.RedmineManager.Create(issueRelation, secondIssue.Id.ToInvariantString()); + + //Act + var retrievedIssue = fixture.RedmineManager.Get(secondIssue.Id.ToInvariantString(), + RequestOptions.Include($"{Include.Issue.Watchers},{Include.Issue.Relations}")); + + //Assert + IssueTestHelper.AssertBasic(secondIssue, retrievedIssue); + Assert.NotNull(retrievedIssue.Watchers); + Assert.NotNull(retrievedIssue.Relations); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTestsAsync.cs new file mode 100644 index 00000000..1e39a2fc --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueTestsAsync.cs @@ -0,0 +1,157 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueTestsAsync(RedmineTestContainerFixture fixture) +{ + private static readonly IdentifiableName ProjectIdName = IdentifiableName.Create(1); + + private async Task CreateTestIssueAsync(List customFields = null, + List watchers = null) + { + var issue = new Redmine.Net.Api.Types.Issue + { + Project = ProjectIdName, + Subject = RandomHelper.GenerateText(9), + Description = RandomHelper.GenerateText(18), + Tracker = 1.ToIdentifier(), + Status = 1.ToIssueStatusIdentifier(), + Priority = 2.ToIdentifier(), + CustomFields = customFields, + Watchers = watchers + }; + return await fixture.RedmineManager.CreateAsync(issue); + } + + [Fact] + public async Task CreateIssue_Should_Succeed() + { + //Arrange + var issueData = new Redmine.Net.Api.Types.Issue + { + Project = ProjectIdName, + Subject = RandomHelper.GenerateText(9), + Description = RandomHelper.GenerateText(18), + Tracker = 2.ToIdentifier(), + Status = 1.ToIssueStatusIdentifier(), + Priority = 3.ToIdentifier(), + StartDate = DateTime.Now.Date, + DueDate = DateTime.Now.Date.AddDays(7), + EstimatedHours = 8, + CustomFields = + [ + IssueCustomField.CreateSingle(1, RandomHelper.GenerateText(8), RandomHelper.GenerateText(4)) + ] + }; + + //Act + var cr = await fixture.RedmineManager.CreateAsync(issueData); + var createdIssue = await fixture.RedmineManager.GetAsync(cr.Id.ToString()); + + //Assert + Assert.NotNull(createdIssue); + Assert.True(createdIssue.Id > 0); + Assert.Equal(issueData.Subject, createdIssue.Subject); + Assert.Equal(issueData.Description, createdIssue.Description); + Assert.Equal(issueData.Project.Id, createdIssue.Project.Id); + Assert.Equal(issueData.Tracker.Id, createdIssue.Tracker.Id); + Assert.Equal(issueData.Status.Id, createdIssue.Status.Id); + Assert.Equal(issueData.Priority.Id, createdIssue.Priority.Id); + Assert.Equal(issueData.StartDate, createdIssue.StartDate); + Assert.Equal(issueData.DueDate, createdIssue.DueDate); + // Assert.Equal(issueData.EstimatedHours, createdIssue.EstimatedHours); + } + + [Fact] + public async Task GetIssue_Should_Succeed() + { + //Arrange + var createdIssue = await CreateTestIssueAsync(); + Assert.NotNull(createdIssue); + + var issueId = createdIssue.Id.ToInvariantString(); + + //Act + var retrievedIssue = await fixture.RedmineManager.GetAsync(issueId); + + //Assert + Assert.NotNull(retrievedIssue); + Assert.Equal(createdIssue.Id, retrievedIssue.Id); + Assert.Equal(createdIssue.Subject, retrievedIssue.Subject); + Assert.Equal(createdIssue.Description, retrievedIssue.Description); + Assert.Equal(createdIssue.Project.Id, retrievedIssue.Project.Id); + } + + [Fact] + public async Task UpdateIssue_Should_Succeed() + { + //Arrange + var createdIssue = await CreateTestIssueAsync(); + Assert.NotNull(createdIssue); + + var updatedSubject = RandomHelper.GenerateText(9); + var updatedDescription = RandomHelper.GenerateText(18); + var updatedStatusId = 2; + + createdIssue.Subject = updatedSubject; + createdIssue.Description = updatedDescription; + createdIssue.Status = updatedStatusId.ToIssueStatusIdentifier(); + createdIssue.Notes = RandomHelper.GenerateText("Note"); + + var issueId = createdIssue.Id.ToInvariantString(); + + //Act + await fixture.RedmineManager.UpdateAsync(issueId, createdIssue); + var retrievedIssue = await fixture.RedmineManager.GetAsync(issueId); + + //Assert + Assert.NotNull(retrievedIssue); + Assert.Equal(createdIssue.Id, retrievedIssue.Id); + Assert.Equal(updatedSubject, retrievedIssue.Subject); + Assert.Equal(updatedDescription, retrievedIssue.Description); + Assert.Equal(updatedStatusId, retrievedIssue.Status.Id); + } + + [Fact] + public async Task DeleteIssue_Should_Succeed() + { + //Arrange + var createdIssue = await CreateTestIssueAsync(); + Assert.NotNull(createdIssue); + + var issueId = createdIssue.Id.ToInvariantString(); + + //Act + await fixture.RedmineManager.DeleteAsync(issueId); + + //Assert + await Assert.ThrowsAsync(async () => await fixture.RedmineManager.GetAsync(issueId)); + } + + [Fact] + public async Task GetIssue_With_Watchers_And_Relations_Should_Succeed() + { + var createdIssue = await CreateTestIssueAsync( + [ + IssueCustomField.CreateMultiple(1, RandomHelper.GenerateText(8), + [RandomHelper.GenerateText(4), RandomHelper.GenerateText(4)]) + ], + [new Watcher() { Id = 1 }]); + + Assert.NotNull(createdIssue); + + //Act + var retrievedIssue = await fixture.RedmineManager.GetAsync(createdIssue.Id.ToInvariantString(), + RequestOptions.Include($"{Include.Issue.Watchers},{Include.Issue.Relations}")); + + //Assert + Assert.NotNull(retrievedIssue); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTests.cs new file mode 100644 index 00000000..c46dc941 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTests.cs @@ -0,0 +1,43 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueWatcherTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void AddWatcher_Should_Succeed() + { + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + const int userId = 1; + + fixture.RedmineManager.AddWatcherToIssue(issue.Id, userId); + + var updated = fixture.RedmineManager.Get( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.WATCHERS)); + + Assert.Contains(updated.Watchers, w => w.Id == userId); + } + + [Fact] + public void RemoveWatcher_Should_Succeed() + { + var (issue, _) = IssueTestHelper.CreateRandomIssue(fixture.RedmineManager); + const int userId = 1; + + fixture.RedmineManager.AddWatcherToIssue(issue.Id, userId); + fixture.RedmineManager.RemoveWatcherFromIssue(issue.Id, userId); + + var updated = fixture.RedmineManager.Get( + issue.Id.ToString(), + RequestOptions.Include(RedmineKeys.WATCHERS)); + + Assert.DoesNotContain(updated.Watchers ?? [], w => w.Id == userId); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTestsAsync.cs new file mode 100644 index 00000000..fb96377f --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Issue/IssueWatcherTestsAsync.cs @@ -0,0 +1,68 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueWatcherTestsAsync(RedmineTestContainerFixture fixture) +{ + private async Task CreateTestIssueAsync() + { + var issue = new Redmine.Net.Api.Types.Issue + { + Project = new IdentifiableName { Id = 1 }, + Tracker = new IdentifiableName { Id = 1 }, + Status = new IssueStatus { Id = 1 }, + Priority = new IdentifiableName { Id = 4 }, + Subject = $"Test issue subject {Guid.NewGuid()}", + Description = "Test issue description" + }; + + return await fixture.RedmineManager.CreateAsync(issue); + } + + [Fact] + public async Task AddWatcher_Should_Succeed() + { + // Arrange + var issue = await CreateTestIssueAsync(); + Assert.NotNull(issue); + + const int userId = 1; + + // Act + await fixture.RedmineManager.AddWatcherToIssueAsync(issue.Id, userId); + + var updatedIssue = await fixture.RedmineManager.GetAsync(issue.Id.ToString(), RequestOptions.Include(RedmineKeys.WATCHERS)); + + // Assert + Assert.NotNull(updatedIssue); + Assert.NotNull(updatedIssue.Watchers); + Assert.Contains(updatedIssue.Watchers, w => w.Id == userId); + } + + [Fact] + public async Task RemoveWatcher_Should_Succeed() + { + // Arrange + var issue = await CreateTestIssueAsync(); + Assert.NotNull(issue); + + const int userId = 1; + + await fixture.RedmineManager.AddWatcherToIssueAsync(issue.Id, userId); + + // Act + await fixture.RedmineManager.RemoveWatcherFromIssueAsync(issue.Id, userId); + + var updatedIssue = await fixture.RedmineManager.GetAsync(issue.Id.ToString(), RequestOptions.Include(RedmineKeys.WATCHERS)); + + // Assert + Assert.NotNull(updatedIssue); + Assert.DoesNotContain(updatedIssue.Watchers ?? [], w => w.Id == userId); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTests.cs new file mode 100644 index 00000000..166cfc6f --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTests.cs @@ -0,0 +1,67 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueCategoryTests(RedmineTestContainerFixture fixture) +{ + private const string PROJECT_ID = "1"; + + private IssueCategory CreateCategory() + { + return fixture.RedmineManager.Create( + new IssueCategory { Name = $"Test Category {Guid.NewGuid()}" }, + PROJECT_ID); + } + + [Fact] + public void GetProjectIssueCategories_Should_Succeed() => + Assert.NotNull(fixture.RedmineManager.Get(PROJECT_ID)); + + [Fact] + public void CreateIssueCategory_Should_Succeed() + { + var cat = new IssueCategory { Name = $"Cat {Guid.NewGuid()}" }; + var created = fixture.RedmineManager.Create(cat, PROJECT_ID); + + Assert.True(created.Id > 0); + Assert.Equal(cat.Name, created.Name); + } + + [Fact] + public void GetIssueCategory_Should_Succeed() + { + var created = CreateCategory(); + var retrieved = fixture.RedmineManager.Get(created.Id.ToInvariantString()); + + Assert.Equal(created.Id, retrieved.Id); + Assert.Equal(created.Name, retrieved.Name); + } + + [Fact] + public void UpdateIssueCategory_Should_Succeed() + { + var created = CreateCategory(); + created.Name = $"Updated {Guid.NewGuid()}"; + + fixture.RedmineManager.Update(created.Id.ToInvariantString(), created); + var retrieved = fixture.RedmineManager.Get(created.Id.ToInvariantString()); + + Assert.Equal(created.Name, retrieved.Name); + } + + [Fact] + public void DeleteIssueCategory_Should_Succeed() + { + var created = CreateCategory(); + var id = created.Id.ToInvariantString(); + + fixture.RedmineManager.Delete(id); + + Assert.Throws(() => fixture.RedmineManager.Get(id)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTestsAsync.cs new file mode 100644 index 00000000..d96361b7 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueCategory/IssueCategoryTestsAsync.cs @@ -0,0 +1,104 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueCategoryTestsAsync(RedmineTestContainerFixture fixture) +{ + private const string PROJECT_ID = "1"; + + private async Task CreateTestIssueCategoryAsync() + { + var category = new IssueCategory + { + Name = $"Test Category {Guid.NewGuid()}" + }; + + return await fixture.RedmineManager.CreateAsync(category, PROJECT_ID); + } + + [Fact] + public async Task GetProjectIssueCategories_Should_Succeed() + { + // Act + var categories = await fixture.RedmineManager.GetAsync(PROJECT_ID); + + // Assert + Assert.NotNull(categories); + } + + [Fact] + public async Task CreateIssueCategory_Should_Succeed() + { + // Arrange + var category = new IssueCategory + { + Name = $"Test Category {Guid.NewGuid()}" + }; + + // Act + var createdCategory = await fixture.RedmineManager.CreateAsync(category, PROJECT_ID); + + // Assert + Assert.NotNull(createdCategory); + Assert.True(createdCategory.Id > 0); + Assert.Equal(category.Name, createdCategory.Name); + } + + [Fact] + public async Task GetIssueCategory_Should_Succeed() + { + // Arrange + var createdCategory = await CreateTestIssueCategoryAsync(); + Assert.NotNull(createdCategory); + + // Act + var retrievedCategory = await fixture.RedmineManager.GetAsync(createdCategory.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedCategory); + Assert.Equal(createdCategory.Id, retrievedCategory.Id); + Assert.Equal(createdCategory.Name, retrievedCategory.Name); + } + + [Fact] + public async Task UpdateIssueCategory_Should_Succeed() + { + // Arrange + var createdCategory = await CreateTestIssueCategoryAsync(); + Assert.NotNull(createdCategory); + + var updatedName = $"Updated Test Category {Guid.NewGuid()}"; + createdCategory.Name = updatedName; + + // Act + await fixture.RedmineManager.UpdateAsync(createdCategory.Id.ToInvariantString(), createdCategory); + var retrievedCategory = await fixture.RedmineManager.GetAsync(createdCategory.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedCategory); + Assert.Equal(createdCategory.Id, retrievedCategory.Id); + Assert.Equal(updatedName, retrievedCategory.Name); + } + + [Fact] + public async Task DeleteIssueCategory_Should_Succeed() + { + // Arrange + var createdCategory = await CreateTestIssueCategoryAsync(); + Assert.NotNull(createdCategory); + + var categoryId = createdCategory.Id.ToInvariantString(); + + // Act + await fixture.RedmineManager.DeleteAsync(categoryId); + + // Assert + await Assert.ThrowsAsync(async () => + await fixture.RedmineManager.GetAsync(categoryId)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTests.cs new file mode 100644 index 00000000..d2f72d88 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTests.cs @@ -0,0 +1,36 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueRelationTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void CreateIssueRelation_Should_Succeed() + { + var (relation, i1, i2) = IssueTestHelper.CreateRandomIssueRelation(fixture.RedmineManager); + + Assert.NotNull(relation); + Assert.True(relation.Id > 0); + Assert.Equal(i1.Id, relation.IssueId); + Assert.Equal(i2.Id, relation.IssueToId); + } + + [Fact] + public void DeleteIssueRelation_Should_Succeed() + { + var (rel, _, _) = IssueTestHelper.CreateRandomIssueRelation(fixture.RedmineManager); + fixture.RedmineManager.Delete(rel.Id.ToString()); + + var issue = fixture.RedmineManager.Get( + rel.IssueId.ToString(), + RequestOptions.Include(RedmineKeys.RELATIONS)); + + Assert.Null(issue.Relations?.FirstOrDefault(r => r.Id == rel.Id)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTestsAsync.cs new file mode 100644 index 00000000..5adbd6d5 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueRelation/IssueRelationTestsAsync.cs @@ -0,0 +1,51 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueRelationTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task CreateIssueRelation_Should_Succeed() + { + // Arrange + var (issue1, issue2) = await IssueTestHelper.CreateRandomTwoIssuesAsync(fixture.RedmineManager); + + var relation = new IssueRelation + { + IssueId = issue1.Id, + IssueToId = issue2.Id, + Type = IssueRelationType.Relates + }; + + // Act + var createdRelation = await fixture.RedmineManager.CreateAsync(relation, issue1.Id.ToString()); + + // Assert + Assert.NotNull(createdRelation); + Assert.True(createdRelation.Id > 0); + Assert.Equal(relation.IssueId, createdRelation.IssueId); + Assert.Equal(relation.IssueToId, createdRelation.IssueToId); + Assert.Equal(relation.Type, createdRelation.Type); + } + + [Fact] + public async Task DeleteIssueRelation_Should_Succeed() + { + // Arrange + var (relation, _, _) = await IssueTestHelper.CreateRandomIssueRelationAsync(fixture.RedmineManager); + Assert.NotNull(relation); + + // Act & Assert + await fixture.RedmineManager.DeleteAsync(relation.Id.ToString()); + + var issue = await fixture.RedmineManager.GetAsync(relation.IssueId.ToString(), RequestOptions.Include(RedmineKeys.RELATIONS)); + + Assert.Null(issue.Relations?.FirstOrDefault(r => r.Id == relation.Id)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTests.cs new file mode 100644 index 00000000..94e099c1 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTests.cs @@ -0,0 +1,17 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueStatusTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetAllIssueStatuses_Should_Succeed() + { + var statuses = fixture.RedmineManager.Get(); + Assert.NotNull(statuses); + Assert.NotEmpty(statuses); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTestsAsync.cs new file mode 100644 index 00000000..5422befa --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/IssueStatus/IssueStatusTestsAsync.cs @@ -0,0 +1,20 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class IssueStatusAsyncTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllIssueStatuses_Should_Succeed() + { + // Act + var statuses = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(statuses); + Assert.NotEmpty(statuses); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTests.cs new file mode 100644 index 00000000..ed59ccae --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTests.cs @@ -0,0 +1,40 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class JournalTests(RedmineTestContainerFixture fixture) +{ + private Redmine.Net.Api.Types.Issue CreateRandomIssue() + { + var issue = TestEntityFactory.CreateRandomIssuePayload(); + return fixture.RedmineManager.Create(issue); + } + + [Fact] + public void Get_Issue_With_Journals_Should_Succeed() + { + // Arrange + var testIssue = CreateRandomIssue(); + Assert.NotNull(testIssue); + + testIssue.Notes = "This is a test note to create a journal entry."; + fixture.RedmineManager.Update(testIssue.Id.ToInvariantString(), testIssue); + + // Act + var issueWithJournals = fixture.RedmineManager.Get( + testIssue.Id.ToInvariantString(), + RequestOptions.Include(RedmineKeys.JOURNALS)); + + // Assert + Assert.NotNull(issueWithJournals); + Assert.NotNull(issueWithJournals.Journals); + Assert.True(issueWithJournals.Journals.Count > 0); + Assert.Contains(issueWithJournals.Journals, j => j.Notes == testIssue.Notes); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTestsAsync.cs new file mode 100644 index 00000000..608bf1a7 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Journal/JournalTestsAsync.cs @@ -0,0 +1,43 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Issue; + +[Collection(Constants.RedmineTestContainerCollection)] +public class JournalTestsAsync(RedmineTestContainerFixture fixture) +{ + private async Task CreateRandomIssueAsync() + { + var issuePayload = TestEntityFactory.CreateRandomIssuePayload(); + return await fixture.RedmineManager.CreateAsync(issuePayload); + } + + [Fact] + public async Task Get_Issue_With_Journals_Should_Succeed() + { + //Arrange + var testIssue = await CreateRandomIssueAsync(); + Assert.NotNull(testIssue); + + var issueIdToTest = testIssue.Id.ToInvariantString(); + + testIssue.Notes = "This is a test note to create a journal entry."; + await fixture.RedmineManager.UpdateAsync(issueIdToTest, testIssue); + + //Act + var issueWithJournals = await fixture.RedmineManager.GetAsync( + issueIdToTest, + RequestOptions.Include(RedmineKeys.JOURNALS)); + + //Assert + Assert.NotNull(issueWithJournals); + Assert.NotNull(issueWithJournals.Journals); + Assert.True(issueWithJournals.Journals.Count > 0, "Issue should have journal entries."); + Assert.Contains(issueWithJournals.Journals, j => j.Notes == testIssue.Notes); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTests.cs new file mode 100644 index 00000000..190d25b7 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTests.cs @@ -0,0 +1,32 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.News; + +[Collection(Constants.RedmineTestContainerCollection)] +public class NewsTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetAllNews_Should_Succeed() + { + _ = fixture.RedmineManager.AddProjectNews(TestConstants.Projects.DefaultProjectIdentifier, TestEntityFactory.CreateRandomNewsPayload()); + + _ = fixture.RedmineManager.AddProjectNews("2", TestEntityFactory.CreateRandomNewsPayload()); + + var news = fixture.RedmineManager.Get(); + + Assert.NotNull(news); + } + + [Fact] + public void GetProjectNews_Should_Succeed() + { + _ = fixture.RedmineManager.AddProjectNews(TestConstants.Projects.DefaultProjectIdentifier, TestEntityFactory.CreateRandomNewsPayload()); + + var news = fixture.RedmineManager.GetProjectNews(TestConstants.Projects.DefaultProjectIdentifier); + + Assert.NotNull(news); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTestsAsync.cs new file mode 100644 index 00000000..88945ab2 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/News/NewsTestsAsync.cs @@ -0,0 +1,52 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.News; + +[Collection(Constants.RedmineTestContainerCollection)] +public class NewsTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllNews_Should_Succeed() + { + // Arrange + _ = await fixture.RedmineManager.AddProjectNewsAsync(TestConstants.Projects.DefaultProjectIdentifier, TestEntityFactory.CreateRandomNewsPayload()); + + _ = await fixture.RedmineManager.AddProjectNewsAsync("2", TestEntityFactory.CreateRandomNewsPayload()); + + // Act + var news = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(news); + } + + [Fact] + public async Task GetProjectNews_Should_Succeed() + { + // Arrange + var newsCreated = await fixture.RedmineManager.AddProjectNewsAsync(TestConstants.Projects.DefaultProjectIdentifier, TestEntityFactory.CreateRandomNewsPayload()); + + // Act + var news = await fixture.RedmineManager.GetProjectNewsAsync(TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(news); + } + + [Fact] + public async Task News_AddWithUploads_Should_Succeed() + { + // Arrange + var newsPayload = TestEntityFactory.CreateRandomNewsPayload(); + var newsCreated = await fixture.RedmineManager.AddProjectNewsAsync(TestConstants.Projects.DefaultProjectIdentifier, newsPayload); + + // Act + var news = await fixture.RedmineManager.GetProjectNewsAsync(TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(news); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Project/ProjectTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Project/ProjectTestsAsync.cs new file mode 100644 index 00000000..aaec371b --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Project/ProjectTestsAsync.cs @@ -0,0 +1,85 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Project; + +[Collection(Constants.RedmineTestContainerCollection)] +public class ProjectTestsAsync(RedmineTestContainerFixture fixture) +{ + private async Task CreateEntityAsync(string subjectSuffix = null) + { + var entity = new Redmine.Net.Api.Types.Project + { + Identifier = RandomHelper.GenerateText(5).ToLowerInvariant(), + Name = "test-random", + }; + + return await fixture.RedmineManager.CreateAsync(entity); + } + + [Fact] + public async Task CreateProject_Should_Succeed() + { + //Arrange + var projectName = RandomHelper.GenerateText(7); + var data = new Redmine.Net.Api.Types.Project + { + Name = projectName, + Identifier = projectName.ToLowerInvariant(), + Description = RandomHelper.GenerateText(7), + HomePage = RandomHelper.GenerateText(7), + IsPublic = true, + InheritMembers = true, + + EnabledModules = [ + new ProjectEnabledModule("files"), + new ProjectEnabledModule("wiki") + ], + + Trackers = + [ + new ProjectTracker(1), + new ProjectTracker(2), + new ProjectTracker(3), + ], + + //CustomFieldValues = [IdentifiableName.Create(1, "cf1"), IdentifiableName.Create(2, "cf2")] + // IssueCustomFields = + // [ + // IssueCustomField.CreateSingle(1, RandomHelper.GenerateText(5), RandomHelper.GenerateText(7)) + // ] + }; + + //Act + var createdProject = await fixture.RedmineManager.CreateAsync(data); + Assert.NotNull(createdProject); + } + + [Fact] + public async Task DeleteIssue_Should_Succeed() + { + //Arrange + var createdEntity = await CreateEntityAsync("DeleteTest"); + Assert.NotNull(createdEntity); + + var id = createdEntity.Id.ToInvariantString(); + + //Act + await fixture.RedmineManager.DeleteAsync(id); + + await Task.Delay(200); + + //Assert + await Assert.ThrowsAsync(TestCode); + return; + + async Task TestCode() + { + await fixture.RedmineManager.GetAsync(id); + } + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTests.cs new file mode 100644 index 00000000..c9cca3ba --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTests.cs @@ -0,0 +1,84 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.ProjectMembership; + +[Collection(Constants.RedmineTestContainerCollection)] +public class ProjectMembershipTests(RedmineTestContainerFixture fixture) +{ + private Redmine.Net.Api.Types.ProjectMembership CreateRandomProjectMembership() + { + var roles = fixture.RedmineManager.Get(); + Assert.NotEmpty(roles); + + var user = TestEntityFactory.CreateRandomUserPayload(); + var createdUser = fixture.RedmineManager.Create(user); + Assert.NotNull(createdUser); + + var membership = new Redmine.Net.Api.Types.ProjectMembership + { + User = new IdentifiableName { Id = createdUser.Id }, + Roles = [new MembershipRole { Id = roles[0].Id }] + }; + + return fixture.RedmineManager.Create(membership, TestConstants.Projects.DefaultProjectIdentifier); + } + + [Fact] + public void GetProjectMemberships_WithValidProjectId_ShouldReturnMemberships() + { + var memberships = fixture.RedmineManager.GetProjectMemberships(TestConstants.Projects.DefaultProjectIdentifier); + Assert.NotNull(memberships); + } + + [Fact] + public void CreateProjectMembership_WithValidData_ShouldSucceed() + { + var membership = CreateRandomProjectMembership(); + + Assert.NotNull(membership); + Assert.True(membership.Id > 0); + Assert.NotNull(membership.User); + Assert.NotEmpty(membership.Roles); + } + + [Fact] + public void UpdateProjectMembership_WithValidData_ShouldSucceed() + { + var membership = CreateRandomProjectMembership(); + Assert.NotNull(membership); + + var roles = fixture.RedmineManager.Get(); + Assert.NotEmpty(roles); + + var newRoleId = roles.First(r => membership.Roles.All(mr => mr.Id != r.Id)).Id; + membership.Roles = [new MembershipRole { Id = newRoleId }]; + + // Act + fixture.RedmineManager.Update(membership.Id.ToString(), membership); + + var updatedMembership = fixture.RedmineManager.Get(membership.Id.ToString()); + + // Assert + Assert.NotNull(updatedMembership); + Assert.Contains(updatedMembership.Roles, r => r.Id == newRoleId); + } + + [Fact] + public void DeleteProjectMembership_WithValidId_ShouldSucceed() + { + // Arrange + var membership = CreateRandomProjectMembership(); + Assert.NotNull(membership); + + // Act + fixture.RedmineManager.Delete(membership.Id.ToString()); + + // Assert + Assert.Throws(() => fixture.RedmineManager.Get(membership.Id.ToString())); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTestsAsync.cs new file mode 100644 index 00000000..ee17f90f --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/ProjectMembership/ProjectMembershipTestsAsync.cs @@ -0,0 +1,123 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.ProjectMembership; + +[Collection(Constants.RedmineTestContainerCollection)] +public class ProjectMembershipTestsAsync(RedmineTestContainerFixture fixture) +{ + private async Task CreateRandomMembershipAsync() + { + var roles = await fixture.RedmineManager.GetAsync(); + Assert.NotEmpty(roles); + + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var membership = new Redmine.Net.Api.Types.ProjectMembership + { + User = new IdentifiableName { Id = user.Id }, + Roles = [new MembershipRole { Id = roles[0].Id }] + }; + + return await fixture.RedmineManager.CreateAsync(membership, TestConstants.Projects.DefaultProjectIdentifier); + } + + [Fact] + public async Task GetProjectMemberships_WithValidProjectId_ShouldReturnMemberships() + { + // Act + var memberships = await fixture.RedmineManager.GetProjectMembershipsAsync(TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(memberships); + } + + [Fact] + public async Task CreateProjectMembership_WithValidData_ShouldSucceed() + { + // Arrange & Act + var projectMembership = await CreateRandomMembershipAsync(); + + // Assert + Assert.NotNull(projectMembership); + Assert.True(projectMembership.Id > 0); + Assert.NotNull(projectMembership.User); + Assert.NotEmpty(projectMembership.Roles); + } + + [Fact] + public async Task UpdateProjectMembership_WithValidData_ShouldSucceed() + { + // Arrange + var membership = await CreateRandomMembershipAsync(); + Assert.NotNull(membership); + + var roles = await fixture.RedmineManager.GetAsync(); + Assert.NotEmpty(roles); + + var newRoleId = roles.FirstOrDefault(r => membership.Roles.All(mr => mr.Id != r.Id))?.Id ?? roles.First().Id; + membership.Roles = [new MembershipRole { Id = newRoleId }]; + + // Act + await fixture.RedmineManager.UpdateAsync(membership.Id.ToString(), membership); + + var updatedMembership = await fixture.RedmineManager.GetAsync(membership.Id.ToString()); + + // Assert + Assert.NotNull(updatedMembership); + Assert.Contains(updatedMembership.Roles, r => r.Id == newRoleId); + } + + [Fact] + public async Task DeleteProjectMembership_WithValidId_ShouldSucceed() + { + // Arrange + var membership = await CreateRandomMembershipAsync(); + Assert.NotNull(membership); + + var membershipId = membership.Id.ToString(); + + // Act + await fixture.RedmineManager.DeleteAsync(membershipId); + + // Assert + await Assert.ThrowsAsync(() => fixture.RedmineManager.GetAsync(membershipId)); + } + + [Fact] + public async Task GetProjectMemberships_ShouldReturnMemberships() + { + // Test implementation + } + + [Fact] + public async Task GetProjectMembership_WithValidId_ShouldReturnMembership() + { + // Test implementation + } + + [Fact] + public async Task CreateProjectMembership_WithInvalidData_ShouldFail() + { + // Test implementation + } + + [Fact] + public async Task UpdateProjectMembership_WithInvalidData_ShouldFail() + { + // Test implementation + } + + [Fact] + public async Task DeleteProjectMembership_WithInvalidId_ShouldFail() + { + // Test implementation + } + +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTests.cs new file mode 100644 index 00000000..b4af0e84 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTests.cs @@ -0,0 +1,18 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Query; + +[Collection(Constants.RedmineTestContainerCollection)] +public class QueryTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void GetAllQueries_Should_Succeed() + { + // Act + var queries = fixture.RedmineManager.Get(); + + // Assert + Assert.NotNull(queries); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTestsAsync.cs new file mode 100644 index 00000000..c8bb6d16 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Query/QueryTestsAsync.cs @@ -0,0 +1,18 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Query; + +[Collection(Constants.RedmineTestContainerCollection)] +public class QueryTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllQueries_Should_Succeed() + { + // Act + var queries = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(queries); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTests.cs new file mode 100644 index 00000000..165c1c4f --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTests.cs @@ -0,0 +1,19 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Role; + +[Collection(Constants.RedmineTestContainerCollection)] +public class RoleTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void Get_All_Roles_Should_Succeed() + { + //Act + var roles = fixture.RedmineManager.Get(); + + //Assert + Assert.NotNull(roles); + Assert.NotEmpty(roles); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTestsAsync.cs new file mode 100644 index 00000000..fb1dbd52 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Role/RoleTestsAsync.cs @@ -0,0 +1,19 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Role; + +[Collection(Constants.RedmineTestContainerCollection)] +public class RoleTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task Get_All_Roles_Should_Succeed() + { + //Act + var roles = await fixture.RedmineManager.GetAsync(); + + //Assert + Assert.NotNull(roles); + Assert.NotEmpty(roles); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTests.cs new file mode 100644 index 00000000..0f4fc789 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTests.cs @@ -0,0 +1,28 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Search; + +[Collection(Constants.RedmineTestContainerCollection)] +public class SearchTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void Search_Should_Succeed() + { + // Arrange + var searchBuilder = new SearchFilterBuilder + { + IncludeIssues = true, + IncludeWikiPages = true + }; + + // Act + var results = fixture.RedmineManager.Search("query_string",100, searchFilter:searchBuilder); + + // Assert + Assert.NotNull(results); + Assert.Null(results.Items); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTestsAsync.cs new file mode 100644 index 00000000..ba9f151e --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Search/SearchTestsAsync.cs @@ -0,0 +1,28 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Search; + +[Collection(Constants.RedmineTestContainerCollection)] +public class SearchTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task Search_Should_Succeed() + { + // Arrange + var searchBuilder = new SearchFilterBuilder + { + IncludeIssues = true, + IncludeWikiPages = true + }; + + // Act + var results = await fixture.RedmineManager.SearchAsync("query_string",100, searchFilter:searchBuilder); + + // Assert + Assert.NotNull(results); + Assert.Null(results.Items); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryActivityTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryActivityTestsAsync.cs new file mode 100644 index 00000000..b2e9546b --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryActivityTestsAsync.cs @@ -0,0 +1,20 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.TimeEntry; + +[Collection(Constants.RedmineTestContainerCollection)] +public class TimeEntryActivityTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task GetAllTimeEntryActivities_Should_Succeed() + { + // Act + var activities = await fixture.RedmineManager.GetAsync(); + + // Assert + Assert.NotNull(activities); + Assert.NotEmpty(activities); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryTestsAsync.cs new file mode 100644 index 00000000..6a273b4d --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/TimeEntry/TimeEntryTestsAsync.cs @@ -0,0 +1,91 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.TimeEntry; + +[Collection(Constants.RedmineTestContainerCollection)] +public class TimeEntryTestsAsync(RedmineTestContainerFixture fixture) +{ + private async Task<(Redmine.Net.Api.Types.TimeEntry, Redmine.Net.Api.Types.TimeEntry payload)> CreateRandomTestTimeEntryAsync() + { + var (issue, _) = await IssueTestHelper.CreateRandomIssueAsync(fixture.RedmineManager); + + var timeEntry = TestEntityFactory.CreateRandomTimeEntryPayload(TestConstants.Projects.DefaultProjectId, issue.Id); + return (await fixture.RedmineManager.CreateAsync(timeEntry), timeEntry); + } + + [Fact] + public async Task CreateTimeEntry_Should_Succeed() + { + //Arrange & Act + var (timeEntry, timeEntryPayload) = await CreateRandomTestTimeEntryAsync(); + + //Assert + Assert.NotNull(timeEntry); + Assert.True(timeEntry.Id > 0); + Assert.Equal(timeEntryPayload.Hours, timeEntry.Hours); + Assert.Equal(timeEntryPayload.Comments, timeEntry.Comments); + Assert.Equal(timeEntryPayload.Project.Id, timeEntry.Project.Id); + Assert.Equal(timeEntryPayload.Issue.Id, timeEntry.Issue.Id); + Assert.Equal(timeEntryPayload.Activity.Id, timeEntry.Activity.Id); + } + + [Fact] + public async Task GetTimeEntry_Should_Succeed() + { + //Arrange + var (createdTimeEntry,_) = await CreateRandomTestTimeEntryAsync(); + Assert.NotNull(createdTimeEntry); + + //Act + var retrievedTimeEntry = await fixture.RedmineManager.GetAsync(createdTimeEntry.Id.ToInvariantString()); + + //Assert + Assert.NotNull(retrievedTimeEntry); + Assert.Equal(createdTimeEntry.Id, retrievedTimeEntry.Id); + Assert.Equal(createdTimeEntry.Hours, retrievedTimeEntry.Hours); + Assert.Equal(createdTimeEntry.Comments, retrievedTimeEntry.Comments); + } + + [Fact] + public async Task UpdateTimeEntry_Should_Succeed() + { + //Arrange + var (createdTimeEntry,_) = await CreateRandomTestTimeEntryAsync(); + Assert.NotNull(createdTimeEntry); + + var updatedComments = $"Updated test time entry comments {Guid.NewGuid()}"; + var updatedHours = 2.5m; + createdTimeEntry.Comments = updatedComments; + createdTimeEntry.Hours = updatedHours; + + //Act + await fixture.RedmineManager.UpdateAsync(createdTimeEntry.Id.ToInvariantString(), createdTimeEntry); + var retrievedTimeEntry = await fixture.RedmineManager.GetAsync(createdTimeEntry.Id.ToInvariantString()); + + //Assert + Assert.NotNull(retrievedTimeEntry); + Assert.Equal(createdTimeEntry.Id, retrievedTimeEntry.Id); + Assert.Equal(updatedComments, retrievedTimeEntry.Comments); + Assert.Equal(updatedHours, retrievedTimeEntry.Hours); + } + + [Fact] + public async Task DeleteTimeEntry_Should_Succeed() + { + //Arrange + var (createdTimeEntry,_) = await CreateRandomTestTimeEntryAsync(); + Assert.NotNull(createdTimeEntry); + + var timeEntryId = createdTimeEntry.Id.ToInvariantString(); + + //Act + await fixture.RedmineManager.DeleteAsync(timeEntryId); + + //Assert + await Assert.ThrowsAsync(async () => await fixture.RedmineManager.GetAsync(timeEntryId)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Tracker/TrackerTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Tracker/TrackerTestsAsync.cs new file mode 100644 index 00000000..c09016c9 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Tracker/TrackerTestsAsync.cs @@ -0,0 +1,19 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Tracker; + +[Collection(Constants.RedmineTestContainerCollection)] +public class TrackerTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task Get_All_Trackers_Should_Succeed() + { + //Act + var trackers = await fixture.RedmineManager.GetAsync(); + + //Assert + Assert.NotNull(trackers); + Assert.NotEmpty(trackers); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/UploadTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/UploadTestsAsync.cs new file mode 100644 index 00000000..4cd293cf --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/UploadTestsAsync.cs @@ -0,0 +1,87 @@ +using System.Text; +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities; + +[Collection(Constants.RedmineTestContainerCollection)] +public class UploadTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task Upload_Attachment_To_Issue_Should_Succeed() + { + var bytes = "Hello World!"u8.ToArray(); + var uploadFile = await fixture.RedmineManager.UploadFileAsync(bytes, "hello-world.txt"); + + Assert.NotNull(uploadFile); + Assert.NotNull(uploadFile.Token); + + var issue = await fixture.RedmineManager.CreateAsync(new Redmine.Net.Api.Types.Issue() + { + Project = 1.ToIdentifier(), + Subject = "Creating an issue with a uploaded file", + Tracker = 1.ToIdentifier(), + Status = 1.ToIssueStatusIdentifier(), + Uploads = [ + new Upload() + { + Token = uploadFile.Token, + ContentType = "text/plain", + Description = "An optional description here", + FileName = "hello-world.txt" + } + ] + }); + + Assert.NotNull(issue); + + var files = await fixture.RedmineManager.GetAsync(issue.Id.ToInvariantString(), RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + Assert.NotNull(files); + Assert.Single(files.Attachments); + } + + [Fact] + public async Task Upload_Attachment_To_Wiki_Should_Succeed() + { + var bytes = Encoding.UTF8.GetBytes(RandomHelper.GenerateText("Hello Wiki!",10)); + var fileName = $"{RandomHelper.GenerateText("wiki-",5)}.txt"; + var uploadFile = await fixture.RedmineManager.UploadFileAsync(bytes, fileName); + + Assert.NotNull(uploadFile); + Assert.NotNull(uploadFile.Token); + + var wikiPageName = RandomHelper.GenerateText(7); + + var wikiPageInfo = new WikiPage() + { + Version = 0, + Comments = RandomHelper.GenerateText(15), + Text = RandomHelper.GenerateText(10), + Uploads = + [ + new Upload() + { + Token = uploadFile.Token, + ContentType = "text/plain", + Description = RandomHelper.GenerateText(15), + FileName = fileName, + } + ] + }; + + var wiki = await fixture.RedmineManager.CreateWikiPageAsync(1.ToInvariantString(), wikiPageName, wikiPageInfo); + + Assert.NotNull(wiki); + + var files = await fixture.RedmineManager.GetWikiPageAsync(1.ToInvariantString(), wikiPageName, RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + Assert.NotNull(files); + Assert.Single(files.Attachments); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/User/UserTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/User/UserTestsAsync.cs new file mode 100644 index 00000000..833f7566 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/User/UserTestsAsync.cs @@ -0,0 +1,242 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Http.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.User; + +[Collection(Constants.RedmineTestContainerCollection)] +public class UserTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task CreateUser_WithValidData_ShouldSucceed() + { + //Arrange + var userPayload = TestEntityFactory.CreateRandomUserPayload(emailNotificationType: EmailNotificationType.OnlyMyEvents); + + //Act + var createdUser = await fixture.RedmineManager.CreateAsync(userPayload); + + //Assert + Assert.NotNull(createdUser); + Assert.True(createdUser.Id > 0); + Assert.Equal(userPayload.Login, createdUser.Login); + Assert.Equal(userPayload.FirstName, createdUser.FirstName); + Assert.Equal(userPayload.LastName, createdUser.LastName); + Assert.Equal(userPayload.Email, createdUser.Email); + } + + [Fact] + public async Task GetUser_WithValidId_ShouldReturnUser() + { + //Arrange + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + //Act + var retrievedUser = await fixture.RedmineManager.GetAsync(user.Id.ToInvariantString()); + + //Assert + Assert.NotNull(retrievedUser); + Assert.Equal(user.Id, retrievedUser.Id); + Assert.Equal(user.Login, retrievedUser.Login); + Assert.Equal(user.FirstName, retrievedUser.FirstName); + } + + [Fact] + public async Task UpdateUser_WithValidData_ShouldSucceed() + { + //Arrange + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + user.FirstName = RandomHelper.GenerateText(10); + + //Act + await fixture.RedmineManager.UpdateAsync(user.Id.ToInvariantString(), user); + var retrievedUser = await fixture.RedmineManager.GetAsync(user.Id.ToInvariantString()); + + //Assert + Assert.NotNull(retrievedUser); + Assert.Equal(user.Id, retrievedUser.Id); + Assert.Equal(user.FirstName, retrievedUser.FirstName); + } + + [Fact] + public async Task DeleteUser_WithValidId_ShouldSucceed() + { + //Arrange + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var userId = user.Id.ToInvariantString(); + + //Act + await fixture.RedmineManager.DeleteAsync(userId); + + //Assert + await Assert.ThrowsAsync(async () => await fixture.RedmineManager.GetAsync(userId)); + } + + [Fact] + public async Task GetCurrentUser_ShouldReturnUserDetails() + { + var currentUser = await fixture.RedmineManager.GetCurrentUserAsync(); + Assert.NotNull(currentUser); + } + + [Fact] + public async Task GetUsers_WithActiveStatus_ShouldReturnUsers() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var users = await fixture.RedmineManager.GetAsync(new RequestOptions() + { + QueryString = RedmineKeys.STATUS.WithItem(((int)UserStatus.StatusActive).ToString()) + }); + + Assert.NotNull(users); + Assert.True(users.Count > 0, "User count == 0"); + } + + [Fact] + public async Task GetUsers_WithLockedStatus_ShouldReturnUsers() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(status: UserStatus.StatusLocked); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var users = await fixture.RedmineManager.GetAsync(new RequestOptions() + { + QueryString = RedmineKeys.STATUS.WithItem(((int)UserStatus.StatusLocked).ToString()) + }); + + Assert.NotNull(users); + Assert.True(users.Count >= 1, "User(Locked) count == 0"); + } + + [Fact] + public async Task GetUsers_WithRegisteredStatus_ShouldReturnUsers() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(status: UserStatus.StatusRegistered); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var users = await fixture.RedmineManager.GetAsync(new RequestOptions() + { + QueryString = RedmineKeys.STATUS.WithInt((int)UserStatus.StatusRegistered) + }); + + Assert.NotNull(users); + Assert.True(users.Count >= 1, "User(Registered) count == 0"); + } + + [Fact] + public async Task GetUser_WithGroupsAndMemberships_ShouldIncludeRelatedData() + { + var roles = await fixture.RedmineManager.GetAsync(); + Assert.NotEmpty(roles); + + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var membership = new Redmine.Net.Api.Types.ProjectMembership + { + User = new IdentifiableName { Id = user.Id }, + Roles = [new MembershipRole { Id = roles[0].Id }] + }; + + var groupPayload = new Redmine.Net.Api.Types.Group() + { + Name = RandomHelper.GenerateText(3), + Users = [IdentifiableName.Create(user.Id)] + }; + + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + // Act + var projectMembership = await fixture.RedmineManager.CreateAsync(membership, TestConstants.Projects.DefaultProjectIdentifier); + Assert.NotNull(projectMembership); + + user = await fixture.RedmineManager.GetAsync(user.Id.ToInvariantString(), + RequestOptions.Include($"{RedmineKeys.GROUPS},{RedmineKeys.MEMBERSHIPS}")); + + Assert.NotNull(user); + Assert.NotNull(user.Groups); + Assert.NotNull(user.Memberships); + + Assert.True(user.Groups.Count > 0, "Group count == 0"); + Assert.True(user.Memberships.Count > 0, "Membership count == 0"); + } + + [Fact] + public async Task GetUsers_ByGroupId_ShouldReturnFilteredUsers() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: [user.Id]); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + var users = await fixture.RedmineManager.GetAsync(new RequestOptions() + { + QueryString = RedmineKeys.GROUP_ID.WithInt(group.Id) + }); + + Assert.NotNull(users); + Assert.True(users.Count > 0, "User count == 0"); + } + + [Fact] + public async Task AddUserToGroup_WithValidIds_ShouldSucceed() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(name: null, userIds: null); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + await fixture.RedmineManager.AddUserToGroupAsync(group.Id, user.Id); + + user = fixture.RedmineManager.Get(user.Id.ToInvariantString(), RequestOptions.Include(RedmineKeys.GROUPS)); + + Assert.NotNull(user); + Assert.NotNull(user.Groups); + Assert.NotNull(user.Groups.FirstOrDefault(g => g.Id == group.Id)); + } + + [Fact] + public async Task RemoveUserFromGroup_WithValidIds_ShouldSucceed() + { + var userPayload = TestEntityFactory.CreateRandomUserPayload(); + var user = await fixture.RedmineManager.CreateAsync(userPayload); + Assert.NotNull(user); + + var groupPayload = TestEntityFactory.CreateRandomGroupPayload(userIds: [user.Id]); + var group = await fixture.RedmineManager.CreateAsync(groupPayload); + Assert.NotNull(group); + + await fixture.RedmineManager.RemoveUserFromGroupAsync(group.Id, user.Id); + + user = await fixture.RedmineManager.GetAsync(user.Id.ToInvariantString(), RequestOptions.Include(RedmineKeys.GROUPS)); + + Assert.NotNull(user); + Assert.True(user.Groups == null || user.Groups.FirstOrDefault(g => g.Id == group.Id) == null); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Version/VersionTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Version/VersionTestsAsync.cs new file mode 100644 index 00000000..0a325719 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Version/VersionTestsAsync.cs @@ -0,0 +1,86 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Types; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Version; + +[Collection(Constants.RedmineTestContainerCollection)] +public class VersionTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task CreateVersion_Should_Succeed() + { + // Arrange + var versionPayload = TestEntityFactory.CreateRandomVersionPayload(); + + // Act + var version = await fixture.RedmineManager.CreateAsync(versionPayload, TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(version); + Assert.True(version.Id > 0); + Assert.Equal(versionPayload.Name, version.Name); + Assert.Equal(versionPayload.Description, version.Description); + Assert.Equal(versionPayload.Status, version.Status); + Assert.Equal(TestConstants.Projects.DefaultProjectId, version.Project.Id); + } + + [Fact] + public async Task GetVersion_Should_Succeed() + { + // Arrange + var versionPayload = TestEntityFactory.CreateRandomVersionPayload(); + var version = await fixture.RedmineManager.CreateAsync(versionPayload, TestConstants.Projects.DefaultProjectIdentifier); + Assert.NotNull(version); + + // Act + var retrievedVersion = await fixture.RedmineManager.GetAsync(version.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedVersion); + Assert.Equal(version.Id, retrievedVersion.Id); + Assert.Equal(version.Name, retrievedVersion.Name); + Assert.Equal(version.Description, retrievedVersion.Description); + } + + [Fact] + public async Task UpdateVersion_Should_Succeed() + { + // Arrange + var versionPayload = TestEntityFactory.CreateRandomVersionPayload(); + var version = await fixture.RedmineManager.CreateAsync(versionPayload, TestConstants.Projects.DefaultProjectIdentifier); + Assert.NotNull(version); + + version.Description = RandomHelper.GenerateText(20); + version.Status = VersionStatus.Locked; + + // Act + await fixture.RedmineManager.UpdateAsync(version.Id.ToString(), version); + var retrievedVersion = await fixture.RedmineManager.GetAsync(version.Id.ToInvariantString()); + + // Assert + Assert.NotNull(retrievedVersion); + Assert.Equal(version.Id, retrievedVersion.Id); + Assert.Equal(version.Description, retrievedVersion.Description); + Assert.Equal(version.Status, retrievedVersion.Status); + } + + [Fact] + public async Task DeleteVersion_Should_Succeed() + { + // Arrange + var versionPayload = TestEntityFactory.CreateRandomVersionPayload(); + var version = await fixture.RedmineManager.CreateAsync(versionPayload, TestConstants.Projects.DefaultProjectIdentifier); + Assert.NotNull(version); + + // Act + await fixture.RedmineManager.DeleteAsync(version); + + // Assert + await Assert.ThrowsAsync(async () => + await fixture.RedmineManager.GetAsync(version)); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Entities/Wiki/WikiTestsAsync.cs b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Wiki/WikiTestsAsync.cs new file mode 100644 index 00000000..d54a28a1 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Entities/Wiki/WikiTestsAsync.cs @@ -0,0 +1,206 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Helpers; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; +using Redmine.Net.Api; +using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Extensions; +using Redmine.Net.Api.Http; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Entities.Wiki; + +[Collection(Constants.RedmineTestContainerCollection)] +public class WikiTestsAsync(RedmineTestContainerFixture fixture) +{ + [Fact] + public async Task CreateWikiPage_WithValidData_ShouldSucceed() + { + // Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + // Act + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + + // Assert + Assert.NotNull(wikiPage); + } + + [Fact] + public async Task GetWikiPage_WithValidTitle_ShouldReturnPage() + { + // Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + // Act + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.NotNull(wikiPage); + + var retrievedPage = await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, wikiPage.Title); + + // Assert + Assert.NotNull(retrievedPage); + Assert.Equal(pageName, retrievedPage.Title); + Assert.Equal(wikiPage.Text, retrievedPage.Text); + } + + [Fact] + public async Task GetAllWikiPages_ForValidProject_ShouldReturnPages() + { + // Arrange + var (firstPageName, firstWikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + var (secondPageName, secondWikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + // Act + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, firstPageName, firstWikiPagePayload); + Assert.NotNull(wikiPage); + + var wikiPage2 = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, secondPageName, secondWikiPagePayload); + Assert.NotNull(wikiPage2); + + // Act + var wikiPages = await fixture.RedmineManager.GetAllWikiPagesAsync(TestConstants.Projects.DefaultProjectIdentifier); + + // Assert + Assert.NotNull(wikiPages); + Assert.NotEmpty(wikiPages); + } + + [Fact] + public async Task DeleteWikiPage_WithValidTitle_ShouldSucceed() + { + // Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + // Act + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.NotNull(wikiPage); + + // Act + await fixture.RedmineManager.DeleteWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, wikiPage.Title); + + // Assert + await Assert.ThrowsAsync(async () => await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, wikiPage.Title)); + } + + [Fact] + public async Task CreateWikiPage_Should_Succeed() + { + // Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + // Act + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + + // Assert + Assert.NotNull(wikiPage); + Assert.NotNull(wikiPage.Author); + Assert.NotNull(wikiPage.CreatedOn); + Assert.Equal(DateTime.Now, wikiPage.CreatedOn.Value, TimeSpan.FromSeconds(5)); + Assert.Equal(pageName, wikiPage.Title); + Assert.Equal(wikiPagePayload.Text, wikiPage.Text); + Assert.Equal(1, wikiPage.Version); + } + + [Fact] + public async Task UpdateWikiPage_WithValidData_ShouldSucceed() + { + // Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.NotNull(wikiPage); + + wikiPage.Text = "Updated wiki text content"; + wikiPage.Comments = "These are updated comments for the wiki page update."; + + // Act + await fixture.RedmineManager.UpdateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPage); + + var retrievedPage = await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, wikiPage.Title); + + // Assert + Assert.NotNull(retrievedPage); + Assert.Equal(wikiPage.Text, retrievedPage.Text); + Assert.Equal(wikiPage.Comments, retrievedPage.Comments); + } + + [Fact] + public async Task GetWikiPage_WithNameAndAttachments_ShouldReturnCompleteData() + { + // Arrange + var fileUpload = await FileTestHelper.UploadRandom500KbFileAsync(fixture.RedmineManager); + Assert.NotNull(fileUpload); + Assert.NotEmpty(fileUpload.Token); + + fileUpload.ContentType = "text/plain"; + fileUpload.Description = RandomHelper.GenerateText(15); + fileUpload.FileName = "hello-world.txt"; + + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(pageName: RandomHelper.GenerateText(prefix: "Te$t"), uploads: [fileUpload]); + + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.NotNull(wikiPage); + + // Act + var page = await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, RequestOptions.Include(RedmineKeys.ATTACHMENTS)); + + // Assert + Assert.NotNull(page); + Assert.Equal(pageName, page.Title); + Assert.NotNull(page.Comments); + Assert.NotNull(page.Author); + Assert.NotNull(page.CreatedOn); + Assert.Equal(DateTime.Now, page.CreatedOn.Value, TimeSpan.FromSeconds(5)); + + Assert.NotNull(page.Attachments); + Assert.NotEmpty(page.Attachments); + + var attachment = page.Attachments.FirstOrDefault(x => x.FileName == fileUpload.FileName); + Assert.NotNull(attachment); + Assert.Equal("text/plain", attachment.ContentType); + Assert.NotNull(attachment.Description); + Assert.Equal(attachment.FileName, attachment.FileName); + Assert.EndsWith($"/attachments/download/{attachment.Id}/{attachment.FileName}", attachment.ContentUrl); + Assert.True(attachment.FileSize > 0); + } + + [Fact] + public async Task GetWikiPage_WithOldVersion_ShouldReturnHistoricalData() + { + //Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(); + + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.NotNull(wikiPage); + + wikiPage.Text = RandomHelper.GenerateText(8); + wikiPage.Comments = RandomHelper.GenerateText(9); + + // Act + await fixture.RedmineManager.UpdateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPage); + + var oldPage = await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, wikiPage.Title, version: 1); + + // Assert + Assert.NotNull(oldPage); + Assert.Equal(wikiPagePayload.Text, oldPage.Text); + Assert.Equal(wikiPagePayload.Comments, oldPage.Comments); + Assert.Equal(1, oldPage.Version); + } + + [Fact] + public async Task GetWikiPage_WithSpecialChars_ShouldReturnPage() + { + //Arrange + var (pageName, wikiPagePayload) = TestEntityFactory.CreateRandomWikiPagePayload(pageName: "some-page-with-umlauts-and-other-special-chars-äöüÄÖÜß"); + + var wikiPage = await fixture.RedmineManager.CreateWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName, wikiPagePayload); + Assert.Null(wikiPage); //it seems that Redmine returns 204 (No content) when the page name contains special characters + + // Act + var page = await fixture.RedmineManager.GetWikiPageAsync(TestConstants.Projects.DefaultProjectIdentifier, pageName); + + // Assert + Assert.NotNull(page); + Assert.Equal(pageName, page.Title); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.Async.cs b/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.Async.cs new file mode 100644 index 00000000..2849cd37 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.Async.cs @@ -0,0 +1,60 @@ +using Redmine.Net.Api.Exceptions; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Progress; + +public partial class ProgressTests +{ + [Fact] + public async Task DownloadFileAsync_WithValidUrl_ShouldReportProgress() + { + // Arrange + var progressTracker = new ProgressTracker(); + + // Act + var result = await fixture.RedmineManager.DownloadFileAsync( + "",null, + progressTracker, + CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.Length > 0, "Downloaded content should not be empty"); + + AssertProgressWasReported(progressTracker); + } + + [Fact] + public async Task DownloadFileAsync_WithCancellation_ShouldStopDownload() + { + // Arrange + var progressTracker = new ProgressTracker(); + var cts = new CancellationTokenSource(); + + try + { + progressTracker.OnProgressReported += (sender, args) => + { + if (args.Value > 0 && !cts.IsCancellationRequested) + { + cts.Cancel(); + } + }; + + // Act & Assert + await Assert.ThrowsAnyAsync(async () => + { + await fixture.RedmineManager.DownloadFileAsync( + "", + null, + progressTracker, + cts.Token); + }); + + Assert.True(progressTracker.ReportCount > 0, "Progress should have been reported at least once"); + } + finally + { + cts.Dispose(); + } + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.cs b/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.cs new file mode 100644 index 00000000..b9ed86a9 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/Tests/Progress/ProgressTests.cs @@ -0,0 +1,56 @@ +using Padi.DotNet.RedmineAPI.Integration.Tests.Fixtures; +using Padi.DotNet.RedmineAPI.Integration.Tests.Infrastructure; + +namespace Padi.DotNet.RedmineAPI.Integration.Tests.Tests.Progress; + +[Collection(Constants.RedmineTestContainerCollection)] +public partial class ProgressTests(RedmineTestContainerFixture fixture) +{ + [Fact] + public void DownloadFile_WithValidUrl_ShouldReportProgress() + { + // Arrange + var progressTracker = new ProgressTracker(); + + // Act + var result = fixture.RedmineManager.DownloadFile("", progressTracker); + + // Assert + Assert.NotNull(result); + Assert.True(result.Length > 0, "Downloaded content should not be empty"); + + AssertProgressWasReported(progressTracker); + } + + private static void AssertProgressWasReported(ProgressTracker tracker) + { + Assert.True(tracker.ReportCount > 0, "Progress should have been reported at least once"); + + Assert.Contains(100, tracker.ProgressValues); + + for (var i = 0; i < tracker.ProgressValues.Count - 1; i++) + { + Assert.True(tracker.ProgressValues[i] <= tracker.ProgressValues[i + 1], + $"Progress should not decrease: {tracker.ProgressValues[i]} -> {tracker.ProgressValues[i + 1]}"); + } + } + + private sealed class ProgressTracker : IProgress + { + public List ProgressValues { get; } = []; + public int ReportCount => ProgressValues.Count; + + public event EventHandler OnProgressReported; + + public void Report(int value) + { + ProgressValues.Add(value); + OnProgressReported?.Invoke(this, new ProgressReportedEventArgs(value)); + } + + public sealed class ProgressReportedEventArgs(int value) : EventArgs + { + public int Value { get; } = value; + } + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Integration.Tests/appsettings.json b/tests/redmine-net-api.Integration.Tests/appsettings.json new file mode 100644 index 00000000..0c75cfe4 --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/appsettings.json @@ -0,0 +1,14 @@ +{ + "TestContainer": { + "Mode": "CreateNewWithRandomPorts", + "Url": "$Url", + "AuthenticationMode": "ApiKey", + "Authentication": { + "Basic":{ + "Username": "$Username", + "Password": "$Password" + }, + "ApiKey": "$ApiKey" + } + } +} diff --git a/tests/redmine-net-api.Integration.Tests/appsettings.local.json b/tests/redmine-net-api.Integration.Tests/appsettings.local.json new file mode 100644 index 00000000..9c508d1e --- /dev/null +++ b/tests/redmine-net-api.Integration.Tests/appsettings.local.json @@ -0,0 +1,10 @@ +{ + "TestContainer": { + "Mode": "UseExisting", + "Url": "http://localhost:8089/", + "AuthenticationMode": "ApiKey", + "Authentication": { + "ApiKey": "61d6fa45ca2c570372b08b8c54b921e5fc39335a" + } + } +} diff --git a/tests/redmine-net-api.Integration.Tests/redmine-net-api.Integration.Tests.csproj b/tests/redmine-net-api.Integration.Tests/redmine-net-api.Integration.Tests.csproj index 5cdab1a7..9f80bcd1 100644 --- a/tests/redmine-net-api.Integration.Tests/redmine-net-api.Integration.Tests.csproj +++ b/tests/redmine-net-api.Integration.Tests/redmine-net-api.Integration.Tests.csproj @@ -1,24 +1,62 @@ - + + + |net40|net45|net451|net452|net46|net461| + |net45|net451|net452|net46|net461| + |net40|net45|net451|net452|net46|net461|net462|net470|net471|net472|net48|net481| + |net45|net451|net452|net46|net461|net462|net470|net471|net472|net48|net481| + + + + DEBUG;TRACE;DEBUG_XML + + + + DEBUG;TRACE;DEBUG_JSON + + net9.0 redmine_net_api.Integration.Tests enable - enable + disable false Padi.DotNet.RedmineAPI.Integration.Tests $(AssemblyName) - - - + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - + @@ -31,4 +69,13 @@ + + + PreserveNewest + + + PreserveNewest + + + diff --git a/tests/redmine-net-api.Tests/Bugs/RedmineApi-229.cs b/tests/redmine-net-api.Tests/Bugs/RedmineApi-229.cs index 64ae736a..2c6bb2b8 100644 --- a/tests/redmine-net-api.Tests/Bugs/RedmineApi-229.cs +++ b/tests/redmine-net-api.Tests/Bugs/RedmineApi-229.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Bugs/RedmineApi-371.cs b/tests/redmine-net-api.Tests/Bugs/RedmineApi-371.cs index d83e4db1..2795f6c6 100644 --- a/tests/redmine-net-api.Tests/Bugs/RedmineApi-371.cs +++ b/tests/redmine-net-api.Tests/Bugs/RedmineApi-371.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; -using Padi.DotNet.RedmineAPI.Tests.Tests; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api; using Redmine.Net.Api.Extensions; -using Redmine.Net.Api.Net; +using Redmine.Net.Api.Http; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Clone/AttachmentCloneTests.cs b/tests/redmine-net-api.Tests/Clone/AttachmentCloneTests.cs index dc8a2830..13990f54 100644 --- a/tests/redmine-net-api.Tests/Clone/AttachmentCloneTests.cs +++ b/tests/redmine-net-api.Tests/Clone/AttachmentCloneTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Clone/IssueCloneTests.cs b/tests/redmine-net-api.Tests/Clone/IssueCloneTests.cs index 0d0b82fa..f41bcd2a 100644 --- a/tests/redmine-net-api.Tests/Clone/IssueCloneTests.cs +++ b/tests/redmine-net-api.Tests/Clone/IssueCloneTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Clone/JournalCloneTests.cs b/tests/redmine-net-api.Tests/Clone/JournalCloneTests.cs index 37440f6e..a6c11719 100644 --- a/tests/redmine-net-api.Tests/Clone/JournalCloneTests.cs +++ b/tests/redmine-net-api.Tests/Clone/JournalCloneTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Equality/AttachmentEqualityTests.cs b/tests/redmine-net-api.Tests/Equality/AttachmentEqualityTests.cs index 6cc72dd1..c604f699 100644 --- a/tests/redmine-net-api.Tests/Equality/AttachmentEqualityTests.cs +++ b/tests/redmine-net-api.Tests/Equality/AttachmentEqualityTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Equality/BaseEqualityTests.cs b/tests/redmine-net-api.Tests/Equality/BaseEqualityTests.cs index 5ff00d44..e6d8672f 100644 --- a/tests/redmine-net-api.Tests/Equality/BaseEqualityTests.cs +++ b/tests/redmine-net-api.Tests/Equality/BaseEqualityTests.cs @@ -1,4 +1,3 @@ -using System; using Xunit; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/IssueEqualityTests.cs b/tests/redmine-net-api.Tests/Equality/IssueEqualityTests.cs index 5060aece..2d137336 100644 --- a/tests/redmine-net-api.Tests/Equality/IssueEqualityTests.cs +++ b/tests/redmine-net-api.Tests/Equality/IssueEqualityTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Equality/JournalEqualityTests.cs b/tests/redmine-net-api.Tests/Equality/JournalEqualityTests.cs index 16f2a073..c0ea67d4 100644 --- a/tests/redmine-net-api.Tests/Equality/JournalEqualityTests.cs +++ b/tests/redmine-net-api.Tests/Equality/JournalEqualityTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Redmine.Net.Api.Types; using Xunit; diff --git a/tests/redmine-net-api.Tests/Equality/MyAccountTests.cs b/tests/redmine-net-api.Tests/Equality/MyAccountTests.cs index 6c0fad96..f098f5f2 100644 --- a/tests/redmine-net-api.Tests/Equality/MyAccountTests.cs +++ b/tests/redmine-net-api.Tests/Equality/MyAccountTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/NewsTests.cs b/tests/redmine-net-api.Tests/Equality/NewsTests.cs index 953775ea..0851518c 100644 --- a/tests/redmine-net-api.Tests/Equality/NewsTests.cs +++ b/tests/redmine-net-api.Tests/Equality/NewsTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/ProjectTests.cs b/tests/redmine-net-api.Tests/Equality/ProjectTests.cs index 2f2ce5a6..c09cb3c6 100644 --- a/tests/redmine-net-api.Tests/Equality/ProjectTests.cs +++ b/tests/redmine-net-api.Tests/Equality/ProjectTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; @@ -28,7 +27,7 @@ protected override Project CreateSampleInstance() new() { Id = 2, Name = "Feature" } ], - CustomFields = + CustomFieldValues = [ new() { Id = 1, Name = "Field1"}, new() { Id = 2, Name = "Field2"} @@ -48,7 +47,8 @@ protected override Project CreateSampleInstance() [ new() { Id = 1, Name = "Activity1" }, new() { Id = 2, Name = "Activity2" } - ] + ], + IssueCustomFields = [IssueCustomField.CreateSingle(1, "SingleCustomField", "SingleCustomFieldValue")] }; } @@ -70,7 +70,7 @@ protected override Project CreateDifferentInstance() [ new() { Id = 3, Name = "Different Bug" } ], - CustomFields = + CustomFieldValues = [ new() { Id = 3, Name = "DifferentField"} ], @@ -85,7 +85,8 @@ protected override Project CreateDifferentInstance() TimeEntryActivities = [ new() { Id = 3, Name = "DifferentActivity" } - ] + ], + IssueCustomFields = [IssueCustomField.CreateSingle(1, "DifferentSingleCustomField", "DifferentSingleCustomFieldValue")] }; } } \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Equality/SearchTests.cs b/tests/redmine-net-api.Tests/Equality/SearchTests.cs index 4962b131..2f5f0707 100644 --- a/tests/redmine-net-api.Tests/Equality/SearchTests.cs +++ b/tests/redmine-net-api.Tests/Equality/SearchTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/TimeEntryTests.cs b/tests/redmine-net-api.Tests/Equality/TimeEntryTests.cs index 6e1c3426..445358ff 100644 --- a/tests/redmine-net-api.Tests/Equality/TimeEntryTests.cs +++ b/tests/redmine-net-api.Tests/Equality/TimeEntryTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/UserTests.cs b/tests/redmine-net-api.Tests/Equality/UserTests.cs index ffc52415..018163ec 100644 --- a/tests/redmine-net-api.Tests/Equality/UserTests.cs +++ b/tests/redmine-net-api.Tests/Equality/UserTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Equality/VersionTests.cs b/tests/redmine-net-api.Tests/Equality/VersionTests.cs index 5d3e06cc..786d82b8 100644 --- a/tests/redmine-net-api.Tests/Equality/VersionTests.cs +++ b/tests/redmine-net-api.Tests/Equality/VersionTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; using Version = Redmine.Net.Api.Types.Version; diff --git a/tests/redmine-net-api.Tests/Equality/WikiPageTests.cs b/tests/redmine-net-api.Tests/Equality/WikiPageTests.cs index 6462f7e6..39284d57 100644 --- a/tests/redmine-net-api.Tests/Equality/WikiPageTests.cs +++ b/tests/redmine-net-api.Tests/Equality/WikiPageTests.cs @@ -1,4 +1,3 @@ -using System; using Redmine.Net.Api.Types; namespace Padi.DotNet.RedmineAPI.Tests.Equality; diff --git a/tests/redmine-net-api.Tests/Infrastructure/Collections/RedmineCollection.cs b/tests/redmine-net-api.Tests/Infrastructure/Collections/RedmineCollection.cs deleted file mode 100644 index 8a30da0c..00000000 --- a/tests/redmine-net-api.Tests/Infrastructure/Collections/RedmineCollection.cs +++ /dev/null @@ -1,10 +0,0 @@ -#if !(NET20 || NET40) -using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; -using Xunit; - -namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Collections -{ - [CollectionDefinition(Constants.RedmineCollection)] - public sealed class RedmineCollection : ICollectionFixture { } -} -#endif \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/JsonSerializerFixture.cs b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/JsonSerializerFixture.cs index d787dd62..35c40898 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/JsonSerializerFixture.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/JsonSerializerFixture.cs @@ -1,4 +1,5 @@ using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Json; namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; diff --git a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineApiUrlsFixture.cs b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineApiUrlsFixture.cs index 7a914dae..a19b44ac 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineApiUrlsFixture.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineApiUrlsFixture.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using Redmine.Net.Api.Net; +using Redmine.Net.Api.Net.Internal; -namespace Padi.DotNet.RedmineAPI.Tests.Tests; +namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; public sealed class RedmineApiUrlsFixture { @@ -26,6 +26,6 @@ private void SetMimeTypeJson() [Conditional("DEBUG_XML")] private void SetMimeTypeXml() { - Format = "json"; + Format = "xml"; } } \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineFixture.cs b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineFixture.cs deleted file mode 100644 index 6865c398..00000000 --- a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/RedmineFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Diagnostics; -using Redmine.Net.Api; -using Redmine.Net.Api.Serialization; - -namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures -{ - public sealed class RedmineFixture - { - public RedmineCredentials Credentials { get; } - public RedmineManager RedmineManager { get; private set; } - - private readonly RedmineManagerOptionsBuilder _redmineManagerOptionsBuilder; - - public RedmineFixture () - { - Credentials = TestHelper.GetApplicationConfiguration(); - - _redmineManagerOptionsBuilder = new RedmineManagerOptionsBuilder() - .WithHost(Credentials.Uri ?? "localhost") - .WithApiKeyAuthentication(Credentials.ApiKey); - - SetMimeTypeXml(); - SetMimeTypeJson(); - - RedmineManager = new RedmineManager(_redmineManagerOptionsBuilder); - } - - [Conditional("DEBUG_JSON")] - private void SetMimeTypeJson() - { - _redmineManagerOptionsBuilder.WithSerializationType(SerializationType.Json); - } - - [Conditional("DEBUG_XML")] - private void SetMimeTypeXml() - { - _redmineManagerOptionsBuilder.WithSerializationType(SerializationType.Xml); - } - } -} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/XmlSerializerFixture.cs b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/XmlSerializerFixture.cs index 700329e1..55f209ec 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Fixtures/XmlSerializerFixture.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Fixtures/XmlSerializerFixture.cs @@ -1,4 +1,5 @@ using Redmine.Net.Api.Serialization; +using Redmine.Net.Api.Serialization.Xml; namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; diff --git a/tests/redmine-net-api.Tests/Infrastructure/Order/CaseOrder.cs b/tests/redmine-net-api.Tests/Infrastructure/Order/CaseOrder.cs index 97ddb56a..b0f8f375 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Order/CaseOrder.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Order/CaseOrder.cs @@ -1,7 +1,5 @@ #if !(NET20 || NET40) using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using Xunit.Abstractions; using Xunit.Sdk; diff --git a/tests/redmine-net-api.Tests/Infrastructure/Order/CollectionOrderer.cs b/tests/redmine-net-api.Tests/Infrastructure/Order/CollectionOrderer.cs index ae7b01da..b1ad5a08 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Order/CollectionOrderer.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Order/CollectionOrderer.cs @@ -1,8 +1,5 @@ #if !(NET20 || NET40) -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using Xunit; using Xunit.Abstractions; diff --git a/tests/redmine-net-api.Tests/Infrastructure/Order/OrderAttribute.cs b/tests/redmine-net-api.Tests/Infrastructure/Order/OrderAttribute.cs index c8f07627..1c7e08e7 100644 --- a/tests/redmine-net-api.Tests/Infrastructure/Order/OrderAttribute.cs +++ b/tests/redmine-net-api.Tests/Infrastructure/Order/OrderAttribute.cs @@ -1,5 +1,3 @@ -using System; - namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure.Order { public sealed class OrderAttribute : Attribute diff --git a/tests/redmine-net-api.Tests/Infrastructure/RedmineCredentials.cs b/tests/redmine-net-api.Tests/Infrastructure/RedmineCredentials.cs deleted file mode 100644 index e3c489be..00000000 --- a/tests/redmine-net-api.Tests/Infrastructure/RedmineCredentials.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Padi.DotNet.RedmineAPI.Tests.Infrastructure -{ - public sealed class RedmineCredentials - { - public string Uri { get; set; } - public string ApiKey { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } -} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Json/AttachmentTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/AttachmentTests.cs new file mode 100644 index 00000000..bd34ff91 --- /dev/null +++ b/tests/redmine-net-api.Tests/Serialization/Json/AttachmentTests.cs @@ -0,0 +1,41 @@ +using Padi.DotNet.RedmineAPI.Tests.Infrastructure; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; +using Xunit; + +namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Json; + +[Collection(Constants.JsonRedmineSerializerCollection)] +public class AttachmentTests(JsonSerializerFixture fixture) +{ + [Fact] + public void Should_Deserialize_Attachment() + { + const string input = """ + { + "attachment": { + "id": 6243, + "filename": "test.txt", + "filesize": 124, + "content_type": "text/plain", + "description": "This is an attachment", + "content_url": "http://localhost:3000/attachments/download/6243/test.txt", + "author": {"name": "Jean-Philippe Lang", "id": 1}, + "created_on": "2011-07-18T22:58:40+02:00" + } + } + """; + + var output = fixture.Serializer.Deserialize(input); + + Assert.NotNull(output); + Assert.Equal(6243, output.Id); + Assert.Equal("test.txt", output.FileName); + Assert.Equal(124, output.FileSize); + Assert.Equal("text/plain", output.ContentType); + Assert.Equal("This is an attachment", output.Description); + Assert.Equal("http://localhost:3000/attachments/download/6243/test.txt", output.ContentUrl); + Assert.Equal("Jean-Philippe Lang", output.Author.Name); + Assert.Equal(1, output.Author.Id); + Assert.Equal(new DateTime(2011, 7, 18, 20, 58, 40, DateTimeKind.Utc).ToLocalTime(), output.CreatedOn); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Json/CustomFieldTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/CustomFieldTests.cs new file mode 100644 index 00000000..18366876 --- /dev/null +++ b/tests/redmine-net-api.Tests/Serialization/Json/CustomFieldTests.cs @@ -0,0 +1,66 @@ +using Padi.DotNet.RedmineAPI.Tests.Infrastructure; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; +using Redmine.Net.Api.Types; +using Xunit; + +namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Json; + +[Collection(Constants.JsonRedmineSerializerCollection)] +public sealed class CustomFieldTests(JsonSerializerFixture fixture) +{ + [Fact] + public void Should_Deserialize_CustomFields() + { + const string input = """ + { + "custom_fields": [ + { + "id": 1, + "name": "Affected version", + "customized_type": "issue", + "field_format": "list", + "regexp": null, + "min_length": null, + "max_length": null, + "is_required": true, + "is_filter": true, + "searchable": true, + "multiple": true, + "default_value": null, + "visible": false, + "possible_values": [ + { + "value": "0.5.x" + }, + { + "value": "0.6.x" + } + ] + } + ], + "total_count": 1 + } + """; + + var output = fixture.Serializer.DeserializeToPagedResults(input); + + Assert.NotNull(output); + Assert.Equal(1, output.TotalItems); + + var customFields = output.Items.ToList(); + Assert.Equal(1, customFields[0].Id); + Assert.Equal("Affected version", customFields[0].Name); + Assert.Equal("issue", customFields[0].CustomizedType); + Assert.Equal("list", customFields[0].FieldFormat); + Assert.True(customFields[0].IsRequired); + Assert.True(customFields[0].IsFilter); + Assert.True(customFields[0].Searchable); + Assert.True(customFields[0].Multiple); + Assert.False(customFields[0].Visible); + + var possibleValues = customFields[0].PossibleValues.ToList(); + Assert.Equal(2, possibleValues.Count); + Assert.Equal("0.5.x", possibleValues[0].Value); + Assert.Equal("0.6.x", possibleValues[1].Value); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Json/ErrorTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/ErrorTests.cs new file mode 100644 index 00000000..889ac9dc --- /dev/null +++ b/tests/redmine-net-api.Tests/Serialization/Json/ErrorTests.cs @@ -0,0 +1,33 @@ +using Padi.DotNet.RedmineAPI.Tests.Infrastructure; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; +using Redmine.Net.Api.Types; +using Xunit; + +namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Json; + +[Collection(Constants.JsonRedmineSerializerCollection)] +public class ErrorTests(JsonSerializerFixture fixture) +{ + [Fact] + public void Should_Deserialize_Errors() + { + const string input = """ + { + "errors":[ + "First name can't be blank", + "Email is invalid" + ], + "total_count":2 + } + """; + + var output = fixture.Serializer.DeserializeToPagedResults(input); + + Assert.NotNull(output); + Assert.Equal(2, output.TotalItems); + + var errors = output.Items.ToList(); + Assert.Equal("First name can't be blank", errors[0].Info); + Assert.Equal("Email is invalid", errors[1].Info); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Json/IssueCustomFieldsTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/IssueCustomFieldsTests.cs new file mode 100644 index 00000000..ee570b6d --- /dev/null +++ b/tests/redmine-net-api.Tests/Serialization/Json/IssueCustomFieldsTests.cs @@ -0,0 +1,43 @@ +using Padi.DotNet.RedmineAPI.Tests.Infrastructure; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; +using Redmine.Net.Api.Types; +using Xunit; + +namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Json; + +[Collection(Constants.JsonRedmineSerializerCollection)] +public class IssueCustomFieldsTests(JsonSerializerFixture fixture) +{ + [Fact] + public void Should_Deserialize_Issue_With_CustomFields_With_Multiple_Values() + { + const string input = """ + { + "custom_fields":[ + {"value":["1.0.1","1.0.2"],"multiple":true,"name":"Affected version","id":1}, + {"value":"Fixed","name":"Resolution","id":2} + ], + "total_count":2 + } + """; + + var output = fixture.Serializer.DeserializeToPagedResults(input); + + Assert.NotNull(output); + Assert.Equal(2, output.TotalItems); + + var customFields = output.Items.ToList(); + + Assert.Equal(1, customFields[0].Id); + Assert.Equal("Affected version", customFields[0].Name); + Assert.True(customFields[0].Multiple); + Assert.Equal(2, customFields[0].Values.Count); + Assert.Equal("1.0.1", customFields[0].Values[0].Info); + Assert.Equal("1.0.2", customFields[0].Values[1].Info); + + Assert.Equal(2, customFields[1].Id); + Assert.Equal("Resolution", customFields[1].Name); + Assert.False(customFields[1].Multiple); + Assert.Equal("Fixed", customFields[1].Values[0].Info); + } +} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Json/IssuesTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/IssuesTests.cs index e1f9965c..adb73da7 100644 --- a/tests/redmine-net-api.Tests/Serialization/Json/IssuesTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Json/IssuesTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Json/UserTests.cs b/tests/redmine-net-api.Tests/Serialization/Json/UserTests.cs new file mode 100644 index 00000000..d7c13243 --- /dev/null +++ b/tests/redmine-net-api.Tests/Serialization/Json/UserTests.cs @@ -0,0 +1,50 @@ +using Padi.DotNet.RedmineAPI.Tests.Infrastructure; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; +using Redmine.Net.Api.Types; +using Xunit; + +namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Json; + +[Collection(Constants.JsonRedmineSerializerCollection)] +public class UserTests(JsonSerializerFixture fixture) +{ + [Fact] + public void Should_Deserialize_User() + { + const string input = """ + { + "user":{ + "id": 3, + "login":"jplang", + "firstname": "Jean-Philippe", + "lastname":"Lang", + "mail":"jp_lang@yahoo.fr", + "created_on": "2007-09-28T00:16:04+02:00", + "updated_on":"2010-08-01T18:05:45+02:00", + "last_login_on":"2011-08-01T18:05:45+02:00", + "passwd_changed_on": "2011-08-01T18:05:45+02:00", + "api_key": "ebc3f6b781a6fb3f2b0a83ce0ebb80e0d585189d", + "avatar_url": "", + "status": 1 + } + } + """; + + var output = fixture.Serializer.Deserialize(input); + + Assert.NotNull(output); + Assert.Equal(3, output.Id); + Assert.Equal("jplang", output.Login); + Assert.Equal("Jean-Philippe", output.FirstName); + Assert.Equal("Lang", output.LastName); + Assert.Equal("jp_lang@yahoo.fr", output.Email); + Assert.Equal(new DateTime(2007, 9, 28, 0, 16, 4, DateTimeKind.Local).AddHours(1), output.CreatedOn); + Assert.Equal(new DateTime(2010, 8, 1, 18, 5, 45, DateTimeKind.Local).AddHours(1), output.UpdatedOn); + Assert.Equal(new DateTime(2011, 8, 1, 18, 5, 45, DateTimeKind.Local).AddHours(1), output.LastLoginOn); + Assert.Equal(new DateTime(2011, 8, 1, 18, 5, 45, DateTimeKind.Local).AddHours(1), output.PasswordChangedOn); + Assert.Equal("ebc3f6b781a6fb3f2b0a83ce0ebb80e0d585189d", output.ApiKey); + Assert.Empty(output.AvatarUrl); + Assert.Equal(UserStatus.StatusActive, output.Status); + } + +} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/AttachmentTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/AttachmentTests.cs index 85caa2ae..9930b97d 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/AttachmentTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/AttachmentTests.cs @@ -1,4 +1,3 @@ -using System; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/CustomFieldTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/CustomFieldTests.cs index 0cb541a9..daa7a02d 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/CustomFieldTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/CustomFieldTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/EnumerationTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/EnumerationTests.cs index 2d870e8c..3c0a6740 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/EnumerationTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/EnumerationTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/ErrorTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/ErrorTests.cs index c5e447a5..a40bd64a 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/ErrorTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/ErrorTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/FileTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/FileTests.cs index 72fbd208..1adc3c59 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/FileTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/FileTests.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; -using Redmine.Net.Api.Types; using Xunit; +using File = Redmine.Net.Api.Types.File; namespace Padi.DotNet.RedmineAPI.Tests.Serialization.Xml; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/GroupTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/GroupTests.cs index 3e057eac..d563fb41 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/GroupTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/GroupTests.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/IssueCategoryTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/IssueCategoryTests.cs index 9bf24bdb..3b658f55 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/IssueCategoryTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/IssueCategoryTests.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/IssueStatusTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/IssueStatusTests.cs index 8e699d0b..58daceca 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/IssueStatusTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/IssueStatusTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/IssueTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/IssueTests.cs index 6791587c..a423b713 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/IssueTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/IssueTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/MembershipTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/MembershipTests.cs index 9bede534..59aab9b9 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/MembershipTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/MembershipTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/NewsTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/NewsTests.cs index 0b29de40..93ebe8d3 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/NewsTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/NewsTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/ProjectTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/ProjectTests.cs index fd0072c7..fa1a9f44 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/ProjectTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/ProjectTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/QueryTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/QueryTests.cs index 5927739a..9e830935 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/QueryTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/QueryTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/RelationTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/RelationTests.cs index f06be8ac..cc9ecd5b 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/RelationTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/RelationTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/RoleTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/RoleTests.cs index 7d695ef5..2790bc89 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/RoleTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/RoleTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/SearchTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/SearchTests.cs index 5c60001f..928cd089 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/SearchTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/SearchTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/TrackerTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/TrackerTests.cs index a61410cb..4d39faa8 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/TrackerTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/TrackerTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/UploadTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/UploadTests.cs index ff191c89..b9693b1a 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/UploadTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/UploadTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/UserTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/UserTests.cs index e24b0a57..39f31e9a 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/UserTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/UserTests.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/VersionTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/VersionTests.cs index 860e1a93..28b7c3b2 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/VersionTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/VersionTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api.Types; diff --git a/tests/redmine-net-api.Tests/Serialization/Xml/WikiTests.cs b/tests/redmine-net-api.Tests/Serialization/Xml/WikiTests.cs index 768ffde0..a3dbdbc5 100644 --- a/tests/redmine-net-api.Tests/Serialization/Xml/WikiTests.cs +++ b/tests/redmine-net-api.Tests/Serialization/Xml/WikiTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Padi.DotNet.RedmineAPI.Tests.Infrastructure; using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Xunit; @@ -79,6 +77,55 @@ public void Should_Deserialize_Wiki_Pages() Assert.Equal(new DateTime(2008, 3, 9, 12, 7, 8, DateTimeKind.Utc).ToLocalTime(), wikiPages[0].CreatedOn); Assert.Equal(new DateTime(2008, 3, 9, 22, 41, 33, DateTimeKind.Utc).ToLocalTime(), wikiPages[0].UpdatedOn); } + + [Fact] + public void Should_Deserialize_Empty_Wiki_Pages() + { + const string input = """ + + + + """; + + var output = fixture.Serializer.DeserializeToPagedResults(input); + + Assert.NotNull(output); + Assert.Equal(0, output.TotalItems); + } + + [Fact] + public void Should_Deserialize_Wiki_With_Attachments() + { + const string input = """ + + + Te$t + QEcISExBVZ + 3 + + uAqCrmSBDUpNMOU + 2025-05-26T16:32:41Z + 2025-05-26T16:43:01Z + + + 155 + test-file_QPqCTEa + 512000 + text/plain + JIIMEcwtuZUsIHY + http://localhost:8089/attachments/download/155/test-file_QPqCTEa + + 2025-05-26T16:32:36Z + + + + """; + + var output = fixture.Serializer.Deserialize(input); + + Assert.NotNull(output); + Assert.Single(output.Attachments); + } } diff --git a/tests/redmine-net-api.Tests/TestHelper.cs b/tests/redmine-net-api.Tests/TestHelper.cs deleted file mode 100644 index fbc1995f..00000000 --- a/tests/redmine-net-api.Tests/TestHelper.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.IO; -using Microsoft.Extensions.Configuration; -using Padi.DotNet.RedmineAPI.Tests.Infrastructure; - -namespace Padi.DotNet.RedmineAPI.Tests -{ - internal static class TestHelper - { - private static IConfigurationRoot GetIConfigurationRoot(string outputPath) - { - var environment = Environment.GetEnvironmentVariable("Environment"); - - return new ConfigurationBuilder() - .SetBasePath(outputPath) - .AddJsonFile("appsettings.json", optional: true) - .AddJsonFile($"appsettings.{environment}.json", optional: true) - .AddUserSecrets("f8b9e946-b547-42f1-861c-f719dca00a84") - .Build(); - } - - public static RedmineCredentials GetApplicationConfiguration(string outputPath = "") - { - if (string.IsNullOrWhiteSpace(outputPath)) - { - outputPath = Directory.GetCurrentDirectory(); - } - - var credentials = new RedmineCredentials(); - - var iConfig = GetIConfigurationRoot(outputPath); - - iConfig - .GetSection("Credentials-Local") - .Bind(credentials); - - return credentials; - } - } -} \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/Tests/HostTests.cs b/tests/redmine-net-api.Tests/Tests/HostTests.cs index 89943a7c..dd4459bb 100644 --- a/tests/redmine-net-api.Tests/Tests/HostTests.cs +++ b/tests/redmine-net-api.Tests/Tests/HostTests.cs @@ -1,6 +1,6 @@ using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Order; -using Redmine.Net.Api; using Redmine.Net.Api.Exceptions; +using Redmine.Net.Api.Options; using Xunit; namespace Padi.DotNet.RedmineAPI.Tests.Tests @@ -13,7 +13,6 @@ public sealed class HostTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - [InlineData("string.Empty")] [InlineData("localhost")] [InlineData("http://")] [InlineData("")] diff --git a/tests/redmine-net-api.Tests/Tests/RedmineApiUrlsTests.cs b/tests/redmine-net-api.Tests/Tests/RedmineApiUrlsTests.cs index 8b0121bb..8f030881 100644 --- a/tests/redmine-net-api.Tests/Tests/RedmineApiUrlsTests.cs +++ b/tests/redmine-net-api.Tests/Tests/RedmineApiUrlsTests.cs @@ -1,10 +1,12 @@ -using System; using System.Collections.Specialized; +using Padi.DotNet.RedmineAPI.Tests.Infrastructure.Fixtures; using Redmine.Net.Api; using Redmine.Net.Api.Exceptions; -using Redmine.Net.Api.Net; +using Redmine.Net.Api.Http; +using Redmine.Net.Api.Net.Internal; using Redmine.Net.Api.Types; using Xunit; +using File = Redmine.Net.Api.Types.File; using Version = Redmine.Net.Api.Types.Version; @@ -12,62 +14,33 @@ namespace Padi.DotNet.RedmineAPI.Tests.Tests; public class RedmineApiUrlsTests(RedmineApiUrlsFixture fixture) : IClassFixture { + private string GetUriWithFormat(string path) + { + return string.Format(path, fixture.Format); + } + [Fact] public void MyAccount_ReturnsCorrectUrl() { var result = fixture.Sut.MyAccount(); - Assert.Equal("my/account.json", result); - } - - [Theory] - [MemberData(nameof(ProjectOperationsData))] - public void ProjectOperations_ReturnsCorrectUrl(string projectId, Func operation, string expected) - { - var result = operation(projectId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat("my/account.{0}"), result); } [Theory] - [MemberData(nameof(WikiOperationsData))] - public void WikiOperations_ReturnsCorrectUrl(string projectId, string pageName, Func operation, string expected) - { - var result = operation(projectId, pageName); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("123", "456", "issues/123/watchers/456.json")] + [InlineData("123", "456", "issues/123/watchers/456.{0}")] public void IssueWatcherRemove_WithValidIds_ReturnsCorrectUrl(string issueId, string userId, string expected) { var result = fixture.Sut.IssueWatcherRemove(issueId, userId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] - [InlineData(null, "456")] - [InlineData("123", null)] - [InlineData("", "456")] - [InlineData("123", "")] - public void IssueWatcherRemove_WithInvalidIds_ThrowsRedmineException(string issueId, string userId) - { - Assert.Throws(() => fixture.Sut.IssueWatcherRemove(issueId, userId)); - } - - [Theory] - [MemberData(nameof(AttachmentOperationsData))] - public void AttachmentOperations_WithValidInput_ReturnsCorrectUrl(string input, Func operation, string expected) - { - var result = operation(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("test.txt", "uploads.json?filename=test.txt")] - [InlineData("file with spaces.pdf", "uploads.json?filename=file%20with%20spaces.pdf")] + [InlineData("test.txt", "uploads.{0}?filename=test.txt")] + [InlineData("file with spaces.pdf", "uploads.{0}?filename=file%20with%20spaces.pdf")] public void UploadFragment_WithFileName_ReturnsCorrectlyEncodedUrl(string fileName, string expected) { var result = fixture.Sut.UploadFragment(fileName); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -75,9 +48,9 @@ public void UploadFragment_WithFileName_ReturnsCorrectlyEncodedUrl(string fileNa [InlineData("project1", "issue_categories")] public void ProjectParentFragment_ForDifferentTypes_ReturnsCorrectUrl(string projectId, string fragment) { - var expected = $"projects/{projectId}/{fragment}.json"; + var expected = $"projects/{projectId}/{fragment}.{{0}}"; var result = fixture.Sut.ProjectParentFragment(projectId, fragment); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -85,9 +58,9 @@ public void ProjectParentFragment_ForDifferentTypes_ReturnsCorrectUrl(string pro [InlineData("issue1", "watchers")] public void IssueParentFragment_ForDifferentTypes_ReturnsCorrectUrl(string issueId, string fragment) { - var expected = $"issues/{issueId}/{fragment}.json"; + var expected = $"issues/{issueId}/{fragment}.{{0}}"; var result = fixture.Sut.IssueParentFragment(issueId, fragment); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -95,7 +68,7 @@ public void IssueParentFragment_ForDifferentTypes_ReturnsCorrectUrl(string issue public void GetFragment_ForAllTypes_ReturnsCorrectUrl(Type type, string id, string expected) { var result = fixture.Sut.GetFragment(type, id); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -103,7 +76,7 @@ public void GetFragment_ForAllTypes_ReturnsCorrectUrl(Type type, string id, stri public void CreateEntity_ForAllTypes_ReturnsCorrectUrl(Type type, string ownerId, string expected) { var result = fixture.Sut.CreateEntityFragment(type, ownerId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -111,7 +84,7 @@ public void CreateEntity_ForAllTypes_ReturnsCorrectUrl(Type type, string ownerId public void GetList_ForAllTypes_ReturnsCorrectUrl(Type type, string ownerId, string expected) { var result = fixture.Sut.GetListFragment(type, ownerId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -135,7 +108,7 @@ public void GetListFragment_WithIssueIdInRequestOptions_ReturnsCorrectUrl(Type t }; var result = fixture.Sut.GetListFragment(type, requestOptions); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -151,7 +124,7 @@ public void GetListFragment_WithProjectIdInRequestOptions_ReturnsCorrectUrl(Type }; var result = fixture.Sut.GetListFragment(type, requestOptions); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -168,7 +141,7 @@ public void GetListFragment_WithBothIds_PrioritizesProjectId(Type type, string p }; var result = fixture.Sut.GetListFragment(type, requestOptions); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -176,7 +149,7 @@ public void GetListFragment_WithBothIds_PrioritizesProjectId(Type type, string p public void GetListFragment_WithNoIds_ReturnsDefaultUrl(Type type, string expected) { var result = fixture.Sut.GetListFragment(type, new RequestOptions()); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -184,7 +157,7 @@ public void GetListFragment_WithNoIds_ReturnsDefaultUrl(Type type, string expect public void GetListFragment_ForAllTypes_ReturnsCorrectUrl(Type type, string parentId, string expected) { var result = fixture.Sut.GetListFragment(type, parentId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -192,7 +165,7 @@ public void GetListFragment_ForAllTypes_ReturnsCorrectUrl(Type type, string pare public void GetListFragment_WithEmptyOptions_ReturnsCorrectUrl(Type type, RequestOptions requestOptions, string expected) { var result = fixture.Sut.GetListFragment(type, requestOptions); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -200,7 +173,7 @@ public void GetListFragment_WithEmptyOptions_ReturnsCorrectUrl(Type type, Reques public void GetListFragment_WithNullOptions_ReturnsCorrectUrl(Type type, string parentId, string expected) { var result = fixture.Sut.GetListFragment(type, parentId); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -208,7 +181,7 @@ public void GetListFragment_WithNullOptions_ReturnsCorrectUrl(Type type, string public void GetListFragment_WithNullRequestOptions_ReturnsDefaultUrl(Type type, string expected) { var result = fixture.Sut.GetListFragment(type, (RequestOptions)null); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Theory] @@ -221,7 +194,7 @@ public void GetListFragment_WithEmptyQueryString_ReturnsDefaultUrl(Type type, st }; var result = fixture.Sut.GetListFragment(type, requestOptions); - Assert.Equal(expected, result); + Assert.Equal(GetUriWithFormat(expected), result); } [Fact] @@ -238,7 +211,7 @@ public void GetListFragment_WithCustomQueryParameters_DoesNotAffectUrl() }; var result = fixture.Sut.GetListFragment(requestOptions); - Assert.Equal("issues.json", result); + Assert.Equal(GetUriWithFormat("issues.{0}"), result); } [Theory] @@ -258,13 +231,13 @@ public static TheoryData GetListWithBothIdsTestDat typeof(Version), "project1", "issue1", - "projects/project1/versions.json" + "projects/project1/versions.{0}" }, { typeof(IssueCategory), "project2", "issue2", - "projects/project2/issue_categories.json" + "projects/project2/issue_categories.{0}" } }; } @@ -273,28 +246,28 @@ public class RedmineTypeTestData : TheoryData { public RedmineTypeTestData() { - Add(null, "issues.json"); - Add(null,"projects.json"); - Add(null,"users.json"); - Add(null,"time_entries.json"); - Add(null,"custom_fields.json"); - Add(null,"groups.json"); - Add(null,"news.json"); - Add(null,"queries.json"); - Add(null,"roles.json"); - Add(null,"issue_statuses.json"); - Add(null,"trackers.json"); - Add(null,"enumerations/issue_priorities.json"); - Add(null,"enumerations/time_entry_activities.json"); - Add("1","projects/1/versions.json"); - Add("1","projects/1/issue_categories.json"); - Add("1","projects/1/memberships.json"); - Add("1","issues/1/relations.json"); - Add(null,"attachments.json"); - Add(null,"custom_fields.json"); - Add(null,"journals.json"); - Add(null,"search.json"); - Add(null,"watchers.json"); + Add(null, "issues.{0}"); + Add(null,"projects.{0}"); + Add(null,"users.{0}"); + Add(null,"time_entries.{0}"); + Add(null,"custom_fields.{0}"); + Add(null,"groups.{0}"); + Add(null,"news.{0}"); + Add(null,"queries.{0}"); + Add(null,"roles.{0}"); + Add(null,"issue_statuses.{0}"); + Add(null,"trackers.{0}"); + Add(null,"enumerations/issue_priorities.{0}"); + Add(null,"enumerations/time_entry_activities.{0}"); + Add("1","projects/1/versions.{0}"); + Add("1","projects/1/issue_categories.{0}"); + Add("1","projects/1/memberships.{0}"); + Add("1","issues/1/relations.{0}"); + Add(null,"attachments.{0}"); + Add(null,"custom_fields.{0}"); + Add(null,"journals.{0}"); + Add(null,"search.{0}"); + Add(null,"watchers.{0}"); } private void Add(string parentId, string expected) where T : class, new() @@ -307,28 +280,28 @@ public static TheoryData GetFragmentTestData() { return new TheoryData { - { typeof(Attachment), "1", "attachments/1.json" }, - { typeof(CustomField), "2", "custom_fields/2.json" }, - { typeof(Group), "3", "groups/3.json" }, - { typeof(Issue), "4", "issues/4.json" }, - { typeof(IssueCategory), "5", "issue_categories/5.json" }, - { typeof(IssueCustomField), "6", "custom_fields/6.json" }, - { typeof(IssuePriority), "7", "enumerations/issue_priorities/7.json" }, - { typeof(IssueRelation), "8", "relations/8.json" }, - { typeof(IssueStatus), "9", "issue_statuses/9.json" }, - { typeof(Journal), "10", "journals/10.json" }, - { typeof(News), "11", "news/11.json" }, - { typeof(Project), "12", "projects/12.json" }, - { typeof(ProjectMembership), "13", "memberships/13.json" }, - { typeof(Query), "14", "queries/14.json" }, - { typeof(Role), "15", "roles/15.json" }, - { typeof(Search), "16", "search/16.json" }, - { typeof(TimeEntry), "17", "time_entries/17.json" }, - { typeof(TimeEntryActivity), "18", "enumerations/time_entry_activities/18.json" }, - { typeof(Tracker), "19", "trackers/19.json" }, - { typeof(User), "20", "users/20.json" }, - { typeof(Version), "21", "versions/21.json" }, - { typeof(Watcher), "22", "watchers/22.json" } + { typeof(Attachment), "1", "attachments/1.{0}" }, + { typeof(CustomField), "2", "custom_fields/2.{0}" }, + { typeof(Group), "3", "groups/3.{0}" }, + { typeof(Issue), "4", "issues/4.{0}" }, + { typeof(IssueCategory), "5", "issue_categories/5.{0}" }, + { typeof(IssueCustomField), "6", "custom_fields/6.{0}" }, + { typeof(IssuePriority), "7", "enumerations/issue_priorities/7.{0}" }, + { typeof(IssueRelation), "8", "relations/8.{0}" }, + { typeof(IssueStatus), "9", "issue_statuses/9.{0}" }, + { typeof(Journal), "10", "journals/10.{0}" }, + { typeof(News), "11", "news/11.{0}" }, + { typeof(Project), "12", "projects/12.{0}" }, + { typeof(ProjectMembership), "13", "memberships/13.{0}" }, + { typeof(Query), "14", "queries/14.{0}" }, + { typeof(Role), "15", "roles/15.{0}" }, + { typeof(Search), "16", "search/16.{0}" }, + { typeof(TimeEntry), "17", "time_entries/17.{0}" }, + { typeof(TimeEntryActivity), "18", "enumerations/time_entry_activities/18.{0}" }, + { typeof(Tracker), "19", "trackers/19.{0}" }, + { typeof(User), "20", "users/20.{0}" }, + { typeof(Version), "21", "versions/21.{0}" }, + { typeof(Watcher), "22", "watchers/22.{0}" } }; } @@ -336,29 +309,29 @@ public static TheoryData CreateEntityTestData() { return new TheoryData { - { typeof(Version), "project1", "projects/project1/versions.json" }, - { typeof(IssueCategory), "project1", "projects/project1/issue_categories.json" }, - { typeof(ProjectMembership), "project1", "projects/project1/memberships.json" }, + { typeof(Version), "project1", "projects/project1/versions.{0}" }, + { typeof(IssueCategory), "project1", "projects/project1/issue_categories.{0}" }, + { typeof(ProjectMembership), "project1", "projects/project1/memberships.{0}" }, - { typeof(IssueRelation), "issue1", "issues/issue1/relations.json" }, + { typeof(IssueRelation), "issue1", "issues/issue1/relations.{0}" }, - { typeof(File), "project1", "projects/project1/files.json" }, - { typeof(Upload), null, "uploads.json" }, - { typeof(Attachment), "issue1", "/attachments/issues/issue1.json" }, + { typeof(File), "project1", "projects/project1/files.{0}" }, + { typeof(Upload), null, "uploads.{0}" }, + { typeof(Attachment), "issue1", "/attachments/issues/issue1.{0}" }, - { typeof(Issue), null, "issues.json" }, - { typeof(Project), null, "projects.json" }, - { typeof(User), null, "users.json" }, - { typeof(TimeEntry), null, "time_entries.json" }, - { typeof(News), null, "news.json" }, - { typeof(Query), null, "queries.json" }, - { typeof(Role), null, "roles.json" }, - { typeof(Group), null, "groups.json" }, - { typeof(CustomField), null, "custom_fields.json" }, - { typeof(IssueStatus), null, "issue_statuses.json" }, - { typeof(Tracker), null, "trackers.json" }, - { typeof(IssuePriority), null, "enumerations/issue_priorities.json" }, - { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.json" } + { typeof(Issue), null, "issues.{0}" }, + { typeof(Project), null, "projects.{0}" }, + { typeof(User), null, "users.{0}" }, + { typeof(TimeEntry), null, "time_entries.{0}" }, + { typeof(News), null, "news.{0}" }, + { typeof(Query), null, "queries.{0}" }, + { typeof(Role), null, "roles.{0}" }, + { typeof(Group), null, "groups.{0}" }, + { typeof(CustomField), null, "custom_fields.{0}" }, + { typeof(IssueStatus), null, "issue_statuses.{0}" }, + { typeof(Tracker), null, "trackers.{0}" }, + { typeof(IssuePriority), null, "enumerations/issue_priorities.{0}" }, + { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.{0}" } }; } @@ -380,28 +353,28 @@ public static TheoryData GetListEntityRequestOptio }; return new TheoryData { - { typeof(Version), rqWithProjectId, "projects/project1/versions.json" }, - { typeof(IssueCategory), rqWithProjectId, "projects/project1/issue_categories.json" }, - { typeof(ProjectMembership), rqWithProjectId, "projects/project1/memberships.json" }, + { typeof(Version), rqWithProjectId, "projects/project1/versions.{0}" }, + { typeof(IssueCategory), rqWithProjectId, "projects/project1/issue_categories.{0}" }, + { typeof(ProjectMembership), rqWithProjectId, "projects/project1/memberships.{0}" }, - { typeof(IssueRelation), rqWithPIssueId, "issues/issue1/relations.json" }, + { typeof(IssueRelation), rqWithPIssueId, "issues/issue1/relations.{0}" }, - { typeof(File), rqWithProjectId, "projects/project1/files.json" }, - { typeof(Attachment), rqWithPIssueId, "attachments.json" }, + { typeof(File), rqWithProjectId, "projects/project1/files.{0}" }, + { typeof(Attachment), rqWithPIssueId, "attachments.{0}" }, - { typeof(Issue), null, "issues.json" }, - { typeof(Project), null, "projects.json" }, - { typeof(User), null, "users.json" }, - { typeof(TimeEntry), null, "time_entries.json" }, - { typeof(News), null, "news.json" }, - { typeof(Query), null, "queries.json" }, - { typeof(Role), null, "roles.json" }, - { typeof(Group), null, "groups.json" }, - { typeof(CustomField), null, "custom_fields.json" }, - { typeof(IssueStatus), null, "issue_statuses.json" }, - { typeof(Tracker), null, "trackers.json" }, - { typeof(IssuePriority), null, "enumerations/issue_priorities.json" }, - { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.json" } + { typeof(Issue), null, "issues.{0}" }, + { typeof(Project), null, "projects.{0}" }, + { typeof(User), null, "users.{0}" }, + { typeof(TimeEntry), null, "time_entries.{0}" }, + { typeof(News), null, "news.{0}" }, + { typeof(Query), null, "queries.{0}" }, + { typeof(Role), null, "roles.{0}" }, + { typeof(Group), null, "groups.{0}" }, + { typeof(CustomField), null, "custom_fields.{0}" }, + { typeof(IssueStatus), null, "issue_statuses.{0}" }, + { typeof(Tracker), null, "trackers.{0}" }, + { typeof(IssuePriority), null, "enumerations/issue_priorities.{0}" }, + { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.{0}" } }; } @@ -409,27 +382,27 @@ public static TheoryData GetListTestData() { return new TheoryData { - { typeof(Version), "project1", "projects/project1/versions.json" }, - { typeof(IssueCategory), "project1", "projects/project1/issue_categories.json" }, - { typeof(ProjectMembership), "project1", "projects/project1/memberships.json" }, + { typeof(Version), "project1", "projects/project1/versions.{0}" }, + { typeof(IssueCategory), "project1", "projects/project1/issue_categories.{0}" }, + { typeof(ProjectMembership), "project1", "projects/project1/memberships.{0}" }, - { typeof(IssueRelation), "issue1", "issues/issue1/relations.json" }, + { typeof(IssueRelation), "issue1", "issues/issue1/relations.{0}" }, - { typeof(File), "project1", "projects/project1/files.json" }, + { typeof(File), "project1", "projects/project1/files.{0}" }, - { typeof(Issue), null, "issues.json" }, - { typeof(Project), null, "projects.json" }, - { typeof(User), null, "users.json" }, - { typeof(TimeEntry), null, "time_entries.json" }, - { typeof(News), null, "news.json" }, - { typeof(Query), null, "queries.json" }, - { typeof(Role), null, "roles.json" }, - { typeof(Group), null, "groups.json" }, - { typeof(CustomField), null, "custom_fields.json" }, - { typeof(IssueStatus), null, "issue_statuses.json" }, - { typeof(Tracker), null, "trackers.json" }, - { typeof(IssuePriority), null, "enumerations/issue_priorities.json" }, - { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.json" } + { typeof(Issue), null, "issues.{0}" }, + { typeof(Project), null, "projects.{0}" }, + { typeof(User), null, "users.{0}" }, + { typeof(TimeEntry), null, "time_entries.{0}" }, + { typeof(News), null, "news.{0}" }, + { typeof(Query), null, "queries.{0}" }, + { typeof(Role), null, "roles.{0}" }, + { typeof(Group), null, "groups.{0}" }, + { typeof(CustomField), null, "custom_fields.{0}" }, + { typeof(IssueStatus), null, "issue_statuses.{0}" }, + { typeof(Tracker), null, "trackers.{0}" }, + { typeof(IssuePriority), null, "enumerations/issue_priorities.{0}" }, + { typeof(TimeEntryActivity), null, "enumerations/time_entry_activities.{0}" } }; } @@ -437,7 +410,7 @@ public static TheoryData GetListWithIssueIdTestData() { return new TheoryData { - { typeof(IssueRelation), "issue1", "issues/issue1/relations.json" }, + { typeof(IssueRelation), "issue1", "issues/issue1/relations.{0}" }, }; } @@ -445,10 +418,10 @@ public static TheoryData GetListWithProjectIdTestData() { return new TheoryData { - { typeof(Version), "1", "projects/1/versions.json" }, - { typeof(IssueCategory), "1", "projects/1/issue_categories.json" }, - { typeof(ProjectMembership), "1", "projects/1/memberships.json" }, - { typeof(File), "1", "projects/1/files.json" }, + { typeof(Version), "1", "projects/1/versions.{0}" }, + { typeof(IssueCategory), "1", "projects/1/issue_categories.{0}" }, + { typeof(ProjectMembership), "1", "projects/1/memberships.{0}" }, + { typeof(File), "1", "projects/1/files.{0}" }, }; } @@ -456,9 +429,9 @@ public static TheoryData GetListWithNullRequestOptionsTestData() { return new TheoryData { - { typeof(Issue), "issues.json" }, - { typeof(Project), "projects.json" }, - { typeof(User), "users.json" } + { typeof(Issue), "issues.{0}" }, + { typeof(Project), "projects.{0}" }, + { typeof(User), "users.{0}" } }; } @@ -466,9 +439,9 @@ public static TheoryData GetListWithEmptyQueryStringTestData() { return new TheoryData { - { typeof(Issue), "issues.json" }, - { typeof(Project), "projects.json" }, - { typeof(User), "users.json" } + { typeof(Issue), "issues.{0}" }, + { typeof(Project), "projects.{0}" }, + { typeof(User), "users.{0}" } }; } @@ -487,11 +460,11 @@ public static TheoryData GetListWithNoIdsTestData() { return new TheoryData { - { typeof(Issue), "issues.json" }, - { typeof(Project), "projects.json" }, - { typeof(User), "users.json" }, - { typeof(TimeEntry), "time_entries.json" }, - { typeof(CustomField), "custom_fields.json" } + { typeof(Issue), "issues.{0}" }, + { typeof(Project), "projects.{0}" }, + { typeof(User), "users.{0}" }, + { typeof(TimeEntry), "time_entries.{0}" }, + { typeof(CustomField), "custom_fields.{0}" } }; } @@ -503,71 +476,4 @@ public static TheoryData InvalidTypeTestData() typeof(int) ]; } - - public static TheoryData, string> AttachmentOperationsData() - { - var fixture = new RedmineApiUrlsFixture(); - return new TheoryData, string> - { - { - "123", - id => fixture.Sut.AttachmentUpdate(id), - "attachments/issues/123.json" - }, - { - "456", - id => fixture.Sut.IssueWatcherAdd(id), - "issues/456/watchers.json" - } - }; - } - - public static TheoryData, string> ProjectOperationsData() - { - var fixture = new RedmineApiUrlsFixture(); - return new TheoryData, string> - { - { - "test-project", - id => fixture.Sut.ProjectClose(id), - "projects/test-project/close.json" - }, - { - "test-project", - id => fixture.Sut.ProjectReopen(id), - "projects/test-project/reopen.json" - }, - { - "test-project", - id => fixture.Sut.ProjectArchive(id), - "projects/test-project/archive.json" - }, - { - "test-project", - id => fixture.Sut.ProjectUnarchive(id), - "projects/test-project/unarchive.json" - } - }; - } - - public static TheoryData, string> WikiOperationsData() - { - var fixture = new RedmineApiUrlsFixture(); - return new TheoryData, string> - { - { - "project1", - "page1", - (id, page) => fixture.Sut.ProjectWikiPage(id, page), - "projects/project1/wiki/page1.json" - }, - { - "project1", - "page1", - (id, page) => fixture.Sut.ProjectWikiPageCreate(id, page), - "projects/project1/wiki/page1.json" - } - }; - } - } \ No newline at end of file diff --git a/tests/redmine-net-api.Tests/appsettings.json b/tests/redmine-net-api.Tests/appsettings.json deleted file mode 100644 index 75421bd0..00000000 --- a/tests/redmine-net-api.Tests/appsettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Credentials": { - "Uri": "$Uri", - "ApiKey": "$ApiKey", - "Username": "$Username", - "Password": "$Password" - }, - "Credentials-Local":{ - "Uri": "$Uri", - "ApiKey": "$ApiKey", - "Username": "$Username", - "Password": "$Password" - } -} diff --git a/tests/redmine-net-api.Tests/redmine-net-api.Tests.csproj b/tests/redmine-net-api.Tests/redmine-net-api.Tests.csproj index 87deb601..0eb2e805 100644 --- a/tests/redmine-net-api.Tests/redmine-net-api.Tests.csproj +++ b/tests/redmine-net-api.Tests/redmine-net-api.Tests.csproj @@ -4,6 +4,8 @@ Padi.DotNet.RedmineAPI.Tests + disable + enable $(AssemblyName) false net481 @@ -15,8 +17,8 @@ |net40|net45|net451|net452|net46|net461| |net45|net451|net452|net46|net461| - |net40|net45|net451|net452|net46|net461|net462|net470|net471|net472|net48| - |net45|net451|net452|net46|net461|net462|net470|net471|net472|net48| + |net40|net45|net451|net452|net46|net461|net462|net470|net471|net472|net48|net481| + |net45|net451|net452|net46|net461|net462|net470|net471|net472|net48|net481| @@ -36,35 +38,13 @@ - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -73,10 +53,4 @@ - - - PreserveNewest - - - \ No newline at end of file