diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..7b66a62f
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+buy_me_a_coffee: vXCNnz9
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..87bc8a46
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,39 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: 'type: bug'
+assignees: zapadi
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Call the Redmine API endpoint '...'
+2. Call the client method: `...`
+3. Inspect the returned response/data
+4. Notice the incorrect behavior or error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Sample Code / Request**
+If applicable, add a minimal code snippet or HTTP request that reproduces the issue.
+
+**API Response / Error**
+If applicable, paste the response body, error message, or stack trace.
+
+**Environment (please complete the following information):**
+- Library version: [e.g. 4.50.0]
+- .NET runtime: [e.g. .NET 8.0, .NET Framework 4.8]
+- Redmine version: [e.g. 5.1.2]
+- OS: [e.g. Windows 11, Ubuntu 22.04]
+
+**Screenshots**
+If applicable, add screenshots to help explain the problem.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..87bd1b13
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,28 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: "[Feature]"
+labels: 'type: enhancement, type: feature'
+assignees: zapadi
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is.
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+Ex: "Add support for calling the `...` endpoint so that I can [...]"
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+Ex: "Right now I make a raw HTTP request using `HttpClient`, but it would be better if the client library exposed [...]"
+
+**Proposed API / Usage Example (if applicable)**
+If relevant, provide a code snippet, method signature, or example API request/response.
+```csharp
+var issue = await client.Issues.GetByIdAsync(123, includeChildren: true);
+```
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
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/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md
index e69de29b..a3f3ba2e 100644
--- a/PULL_REQUEST_TEMPLATE.md
+++ b/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,33 @@
+We welcome contributions!
+
+Here's how you can help:
+
+## Description
+
+
+## Related Issues
+
+- Closes #ISSUE_ID
+- Fixes #ISSUE_ID
+
+## Type of Change
+
+- [ ] π Bug fix (non-breaking change which fixes an issue)
+- [ ] β¨ New feature (non-breaking change which adds functionality)
+- [ ] π₯ Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] π§Ή Code cleanup / refactor
+- [ ] π Documentation update
+
+## Checklist
+
+- [ ] I have tested my changes locally against a Redmine instance.
+- [ ] I have added/updated unit tests if applicable.
+- [ ] I have updated documentation (README / XML comments / wiki).
+- [ ] I followed the projectβs coding style.
+- [ ] New and existing tests pass with my changes.
+
+## API Changes (if applicable)
+
+```csharp
+// Example of a new/updated method signature
+Task GetByIdAsync(int id, bool includeChildren = false);
diff --git a/README.md b/README.md
index 11bb8f6c..9a2add82 100755
--- a/README.md
+++ b/README.md
@@ -1,74 +1,119 @@
+#  redmine-net-api
-
-
-[](https://www.nuget.org/packages/redmine-api)
-
-
+[](https://www.nuget.org/packages/redmine-api)
+[](https://www.nuget.org/packages/redmine-api)
+[](LICENSE)
+[](https://github.com/zapadi/redmine-net-api/graphs/contributors)
-# redmine-net-api 
+A modern and flexible .NET client library to interact with [Redmine](https://www.redmine.org)'s REST API.
-redmine-net-api is a library for communicating with a Redmine project management application.
-* Uses [Redmine's REST API.](http://www.redmine.org/projects/redmine/wiki/Rest_api/)
-* Supports both XML and **JSON** formats.
-* Supports GZipped responses from servers.
-* This API provides access and basic CRUD operations (create, read, update, delete) for the resources described below:
+## π Features
-| Resource | Read | Create | Update | Delete |
-|:--------------------|:-------:|:-------:|:-------:|:-------:|
-| Attachments | ✓ | ✓ | ✗ | ✗ |
-| Custom Fields | ✓ | ✗ | ✗ | ✗ |
-| Enumerations | ✓ | ✗ | ✗ | ✗ |
-| Files | ✓ | ✓ | ✗ | ✗ |
-| Groups | ✓ | ✓ | ✓ | ✓ |
-| Issues | ✓ | ✓ | ✓ | ✓ |
-| Issue Categories | ✓ | ✓ | ✓ | ✓ |
-| Issue Relations | ✓ | ✓ | ✓ | ✓ |
-| Issue Statuses | ✓ | ✗ | ✗ | ✗ |
-| My account | ✓ | ✗ | ✓ | ✗ |
-| News | ✓ | ✓ | ✓ | ✓ |
-| Projects | ✓ | ✓ | ✓ | ✓ |
-| Project Memberships | ✓ | ✓ | ✓ | ✓ |
-| Queries | ✓ | ✗ | ✗ | ✗ |
-| Roles | ✓ | ✗ | ✗ | ✗ |
-| Search | ✓ | | | |
-| Time Entries | ✓ | ✓ | ✓ | ✓ |
-| Trackers | ✓ | ✗ | ✗ | ✗ |
-| Users | ✓ | ✓ | ✓ | ✓ |
-| Versions | ✓ | ✓ | ✓ | ✓ |
-| Wiki Pages | ✓ | ✓ | ✓ | ✓ |
+- Full REST API support with CRUD operations
+- Supports both XML and JSON data formats
+- Handles GZipped server responses transparently
+- Easy integration via NuGet package
+- Actively maintained and community-driven
-## WIKI
+| Resource | Read | Create | Update | Delete |
+|----------------------|:----:|:------:|:------:|:------:|
+| Attachments | β
| β
| β | β |
+| Custom Fields | β
| β | β | β |
+| Enumerations | β
| β | β | β |
+| Files | β
| β
| β | β |
+| Groups | β
| β
| β
| β
|
+| Issues | β
| β
| β
| β
|
+| Issue Categories | β
| β
| β
| β
|
+| Issue Relations | β
| β
| β
| β
|
+| Issue Statuses | β
| β | β | β |
+| My Account | β
| β | β
| β |
+| News | β
| β
| β
| β
|
+| Projects | β
| β
| β
| β
|
+| Project Memberships | β
| β
| β
| β
|
+| Queries | β
| β | β | β |
+| Roles | β
| β | β | β |
+| Search | β
| | | |
+| Time Entries | β
| β
| β
| β
|
+| Trackers | β
| β | β | β |
+| Users | β
| β
| β
| β
|
+| Versions | β
| β
| β
| β
|
+| Wiki Pages | β
| β
| β
| β
|
-Please review the  pages on how to use **redmine-net-api**.
-## Contributing
-Contributions are really appreciated!
+## π¦ Installation
-A good way to get started (flow):
+Add the package via NuGet:
-1. Fork the redmine-net-api repository.
-2. Create a new branch in your current repos from the 'master' branch.
-3. 'Check out' the code with *Git*, *GitHub Desktop*, *SourceTree*, *GitKraken*, *etc*.
-4. Push commits and create a Pull Request (PR) to redmine-net-api.
+```bash
+dotnet add package Redmine.Net.Api
+```
-## License
-[]()
+Or via Package Manager Console:
-The API is released under Apache 2 open-source license. You can use it for both personal and commercial purposes, build upon it and modify it.
+```powershell
+Install-Package Redmine.Net.Api
+```
-## Contributors
-Thanks to all the people who already contributed!
+
+## π§βπ» Usage Example
+
+```csharp
+using Redmine.Net.Api;
+using Redmine.Net.Api.Types;
+using System;
+using System.Threading.Tasks;
+
+class Program
+{
+ static async Task Main()
+ {
+ var options = new RedmineManagerOptionsBuilder()
+ .WithHost("https://your-redmine-url/")
+ .WithApiKeyAuthentication("your-api-key");
+
+ var manager = new RedmineManager(options);
+
+ // Retrieve an issue asynchronously
+ var issue = await manager.GetAsync(12345);
+ Console.WriteLine($"Issue subject: {issue.Subject}");
+ }
+}
+```
+Explore more usage examples on the [Wiki](https://github.com/zapadi/redmine-net-api/wiki).
+
+
+## π Documentation
+
+Detailed API reference, guides, and tutorials are available in the [GitHub Wiki](https://github.com/zapadi/redmine-net-api/wiki).
+
+
+## π Contributing
+
+See the [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
+
+## π¬ Join on Slack
+
+Want to talk about Redmine integration, features, or contribute ideas?
+Join Slack channel here: [dotnet-redmine](https://join.slack.com/t/dotnet-redmine/shared_invite/zt-36cvwm98j-10Sw3w4LITk1N6eqKKHWRw)
+
+
+## π€ Contributors
+
+Thanks to all contributors!
-## Thanks
-I would like to thank:
+## π License
+
+This project is licensed under the [Apache License 2.0](LICENSE).
+
+
+## β Support
-* JetBrains for my Open Source ReSharper licence,
+If you find this project useful, consider ](https://www.buymeacoffee.com/vXCNnz9) to support development.
-* AppVeyor for allowing free build CI services for Open Source projects
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 ab747bba..1f044567 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "8.0.303",
+ "version": "9.0.203",
"allowPrerelease": false,
"rollForward": "latestMajor"
}
diff --git a/redmine-net-api.sln b/redmine-net-api.sln
index 7cbed138..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}"
@@ -52,6 +53,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Others", "Others", "{4ADECA
global.json = global.json
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "redmine-net-api.Integration.Tests", "tests\redmine-net-api.Integration.Tests\redmine-net-api.Integration.Tests.csproj", "{254DABFE-7C92-4C16-84A5-630330D56D4D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -70,6 +73,12 @@ Global
{900EF0B3-0233-45DA-811F-4C59483E8452}.DebugJson|Any CPU.ActiveCfg = DebugJson|Any CPU
{900EF0B3-0233-45DA-811F-4C59483E8452}.DebugJson|Any CPU.Build.0 = DebugJson|Any CPU
{900EF0B3-0233-45DA-811F-4C59483E8452}.Release|Any CPU.ActiveCfg = Debug|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.DebugJson|Any CPU.ActiveCfg = Debug|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.DebugJson|Any CPU.Build.0 = Debug|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {254DABFE-7C92-4C16-84A5-630330D56D4D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -82,6 +91,7 @@ Global
{707B6A3F-1A2C-4EFE-851F-1DB0E68CFFFB} = {E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}
{1D340EEB-C535-45D4-80D7-ADD4434D7B77} = {E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}
{4ADECA2A-4D7B-4F05-85A2-0C0963A83689} = {E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}
+ {254DABFE-7C92-4C16-84A5-630330D56D4D} = {F3F4278D-6271-4F77-BA88-41555D53CBD1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4AA87D90-ABD0-4793-BE47-955B35FAE2BB}
diff --git a/src/redmine-net-api/Authentication/IRedmineAuthentication.cs b/src/redmine-net-api/Authentication/IRedmineAuthentication.cs
index 6823243a..d14d6fcf 100644
--- a/src/redmine-net-api/Authentication/IRedmineAuthentication.cs
+++ b/src/redmine-net-api/Authentication/IRedmineAuthentication.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
diff --git a/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs
index c0a34580..c1d59744 100644
--- a/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs
+++ b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 2e8da6cb..810da00a 100644
--- a/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs
+++ b/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 6fb7fe8a..d8518828 100644
--- a/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs
+++ b/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 90%
rename from src/redmine-net-api/Types/IValue.cs
rename to src/redmine-net-api/Common/IValue.cs
index ae430755..d95d24eb 100755
--- a/src/redmine-net-api/Types/IValue.cs
+++ b/src/redmine-net-api/Common/IValue.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 87%
rename from src/redmine-net-api/Types/PagedResults.cs
rename to src/redmine-net-api/Common/PagedResults.cs
index b6b446e5..af1e82fd 100644
--- a/src/redmine-net-api/Types/PagedResults.cs
+++ b/src/redmine-net-api/Common/PagedResults.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -16,7 +16,7 @@ limitations under the License.
using System.Collections.Generic;
-namespace Redmine.Net.Api.Serialization
+namespace Redmine.Net.Api.Common
{
///
///
@@ -30,14 +30,14 @@ 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;
Offset = offset;
PageSize = pageSize;
- if (pageSize <= 0)
+ if (pageSize <= 0 || total == 0)
{
return;
}
@@ -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/ConflictException.cs b/src/redmine-net-api/Exceptions/ConflictException.cs
deleted file mode 100644
index bc098687..00000000
--- a/src/redmine-net-api/Exceptions/ConflictException.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- ///
- ///
- [Serializable]
- public sealed class ConflictException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ConflictException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public ConflictException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public ConflictException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public ConflictException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public ConflictException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- private ConflictException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/ForbiddenException.cs b/src/redmine-net-api/Exceptions/ForbiddenException.cs
deleted file mode 100644
index 75e6192b..00000000
--- a/src/redmine-net-api/Exceptions/ForbiddenException.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- ///
- ///
- [Serializable]
- public sealed class ForbiddenException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ForbiddenException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public ForbiddenException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public ForbiddenException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public ForbiddenException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public ForbiddenException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- ///
- private ForbiddenException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/InternalServerErrorException.cs b/src/redmine-net-api/Exceptions/InternalServerErrorException.cs
deleted file mode 100644
index ccf3d5aa..00000000
--- a/src/redmine-net-api/Exceptions/InternalServerErrorException.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- ///
- ///
- [Serializable]
- public sealed class InternalServerErrorException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public InternalServerErrorException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public InternalServerErrorException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public InternalServerErrorException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public InternalServerErrorException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public InternalServerErrorException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- ///
- private InternalServerErrorException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/NameResolutionFailureException.cs b/src/redmine-net-api/Exceptions/NameResolutionFailureException.cs
deleted file mode 100644
index 81da3053..00000000
--- a/src/redmine-net-api/Exceptions/NameResolutionFailureException.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- ///
- ///
- [Serializable]
- public sealed class NameResolutionFailureException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public NameResolutionFailureException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public NameResolutionFailureException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NameResolutionFailureException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NameResolutionFailureException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public NameResolutionFailureException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- private NameResolutionFailureException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/NotAcceptableException.cs b/src/redmine-net-api/Exceptions/NotAcceptableException.cs
deleted file mode 100644
index 0c865fbc..00000000
--- a/src/redmine-net-api/Exceptions/NotAcceptableException.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- ///
- ///
- [Serializable]
- public sealed class NotAcceptableException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public NotAcceptableException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public NotAcceptableException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NotAcceptableException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NotAcceptableException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public NotAcceptableException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- private NotAcceptableException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/NotFoundException.cs b/src/redmine-net-api/Exceptions/NotFoundException.cs
deleted file mode 100644
index cde236b1..00000000
--- a/src/redmine-net-api/Exceptions/NotFoundException.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- /// Thrown in case the objects requested for could not be found.
- ///
- ///
- [Serializable]
- public sealed class NotFoundException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public NotFoundException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- public NotFoundException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NotFoundException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- public NotFoundException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- ///
- ///
- ///
- public NotFoundException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- private NotFoundException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineApiException.cs b/src/redmine-net-api/Exceptions/RedmineApiException.cs
index a858e304..fbe168ed 100644
--- a/src/redmine-net-api/Exceptions/RedmineApiException.cs
+++ b/src/redmine-net-api/Exceptions/RedmineApiException.cs
@@ -1,5 +1,11 @@
using System;
+using System.Net;
+#if NET40_OR_GREATER || NET
+using System.Net.Http;
+#endif
+using System.Net.Sockets;
using System.Runtime.Serialization;
+using Redmine.Net.Api.Http.Constants;
namespace Redmine.Net.Api.Exceptions
{
@@ -7,81 +13,196 @@ namespace Redmine.Net.Api.Exceptions
///
///
[Serializable]
- public sealed class RedmineApiException : RedmineException
+ public class RedmineApiException : RedmineException
{
///
- ///
+ /// Gets the error code parameter.
///
- public RedmineApiException()
- : this(errorCode: null, false) { }
-
+ /// The error code associated with the exception.
+ public override string ErrorCode => "REDMINE-API-002";
+
+ ///
+ /// Gets a value indicating whether gets exception is Transient and operation can be retried.
+ ///
+ /// Value indicating whether the exception is transient or not.
+ public bool IsTransient { get; protected set; }
+
///
///
///
- ///
- public RedmineApiException(string message)
- : this(message, errorCode: null, false) { }
-
+ public string Url { get; protected set; }
+
///
///
///
- ///
- ///
- public RedmineApiException(string message, Exception innerException)
- : this(message, innerException, errorCode: null, false) { }
-
+ public int? HttpStatusCode { get; protected set; }
+
///
///
///
- ///
- ///
- public RedmineApiException(string errorCode, bool isTransient)
- : this(string.Empty, errorCode, isTransient) { }
-
+ public RedmineApiException()
+ {
+ }
+
///
///
///
///
- ///
- ///
- public RedmineApiException(string message, string errorCode, bool isTransient)
- : this(message, null, errorCode, isTransient) { }
-
+ public RedmineApiException(string message) : base(message)
+ {
+ }
+
///
///
///
///
- ///
- ///
- ///
- public RedmineApiException(string message, Exception inner, string errorCode, bool isTransient)
- : base(message, inner)
+ ///
+ public RedmineApiException(string message, Exception innerException) : base(message, innerException)
{
- this.ErrorCode = errorCode ?? "UNKNOWN";
- this.IsTransient = isTransient;
+ var transientErrorResult = IsTransientError(InnerException);
+ IsTransient = transientErrorResult.IsTransient;
+ HttpStatusCode = transientErrorResult.StatusCode;
}
-
+
///
- /// Gets the error code parameter.
+ ///
///
- /// The error code associated with the exception.
- public string ErrorCode { get; }
+ ///
+ ///
+ ///
+ ///
+ public RedmineApiException(string message, string url, int? httpStatusCode = null,
+ Exception innerException = null)
+ : base(message, innerException)
+ {
+ Url = url;
+ var transientErrorResult = IsTransientError(InnerException);
+ IsTransient = transientErrorResult.IsTransient;
+ HttpStatusCode = httpStatusCode ?? transientErrorResult.StatusCode;
+ }
///
- /// Gets a value indicating whether gets exception is Transient and operation can be retried.
+ ///
///
- /// Value indicating whether the exception is transient or not.
- public bool IsTransient { get; }
-
- #if !(NET8_0_OR_GREATER)
+ ///
+ ///
+ ///
+ ///
+ public RedmineApiException(string message, Uri requestUri, int? httpStatusCode = null, Exception innerException = null)
+ : base(message, innerException)
+ {
+ Url = requestUri?.ToString();
+ var transientErrorResult = IsTransientError(InnerException);
+ IsTransient = transientErrorResult.IsTransient;
+ HttpStatusCode = httpStatusCode ?? transientErrorResult.StatusCode;
+ }
+
+#if !(NET8_0_OR_GREATER)
///
+ protected RedmineApiException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {
+ Url = info.GetString(nameof(Url));
+ HttpStatusCode = info.GetInt32(nameof(HttpStatusCode));
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
+ info.AddValue(nameof(Url), Url);
+ info.AddValue(nameof(HttpStatusCode), HttpStatusCode);
+ }
+#endif
+
+ internal struct TransientErrorResult(bool isTransient, int? statusCode)
+ {
+ public bool IsTransient = isTransient;
+ public int? StatusCode = statusCode;
+ }
+
+ internal static TransientErrorResult IsTransientError(Exception exception)
+ {
+ return exception switch
+ {
+ null => new TransientErrorResult(false, null),
+ WebException webEx => HandleWebException(webEx),
+#if NET40_OR_GREATER || NET
+ HttpRequestException httpRequestEx => HandleHttpRequestException(httpRequestEx),
+#endif
+ _ => new TransientErrorResult(false, null)
+ };
+ }
+
+ private static TransientErrorResult HandleWebException(WebException webEx)
+ {
+ switch (webEx.Status)
+ {
+ case WebExceptionStatus.ProtocolError when webEx.Response is HttpWebResponse response:
+ var statusCode = (int)response.StatusCode;
+ return new TransientErrorResult(IsTransientStatusCode(statusCode), statusCode);
+
+ case WebExceptionStatus.Timeout:
+ return new TransientErrorResult(true, HttpConstants.StatusCodes.RequestTimeout);
+
+ case WebExceptionStatus.NameResolutionFailure:
+ case WebExceptionStatus.ConnectFailure:
+ return new TransientErrorResult(true, HttpConstants.StatusCodes.ServiceUnavailable);
+
+ default:
+ return new TransientErrorResult(false, null);
+ }
+ }
+
+ private static bool IsTransientStatusCode(int statusCode)
+ {
+ return statusCode == HttpConstants.StatusCodes.TooManyRequests ||
+ statusCode == HttpConstants.StatusCodes.InternalServerError ||
+ statusCode == HttpConstants.StatusCodes.BadGateway ||
+ statusCode == HttpConstants.StatusCodes.ServiceUnavailable ||
+ statusCode == HttpConstants.StatusCodes.GatewayTimeout;
+ }
+
+#if NET40_OR_GREATER || NET
+ private static TransientErrorResult HandleHttpRequestException(
+ HttpRequestException httpRequestEx)
+ {
+ if (httpRequestEx.InnerException is WebException innerWebEx)
+ {
+ return HandleWebException(innerWebEx);
+ }
+
+ if (httpRequestEx.InnerException is SocketException socketEx)
+ {
+ switch (socketEx.SocketErrorCode)
+ {
+ case SocketError.HostNotFound:
+ case SocketError.HostUnreachable:
+ case SocketError.NetworkUnreachable:
+ case SocketError.ConnectionRefused:
+ return new TransientErrorResult(true, HttpConstants.StatusCodes.ServiceUnavailable);
+
+ case SocketError.TimedOut:
+ return new TransientErrorResult(true, HttpConstants.StatusCodes.RequestTimeout);
+
+ default:
+ return new TransientErrorResult(false, null);
+ }
+ }
+
+#if NET
+ if (httpRequestEx.StatusCode.HasValue)
+ {
+ var statusCode = (int)httpRequestEx.StatusCode.Value;
+ return new TransientErrorResult(IsTransientStatusCode(statusCode), statusCode);
+ }
+#endif
- info.AddValue(nameof(this.ErrorCode), this.ErrorCode);
- info.AddValue(nameof(this.IsTransient), this.IsTransient);
+ return new TransientErrorResult(false, null);
}
- #endif
+#endif
}
}
\ 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 ccb10313..95bff717 100644
--- a/src/redmine-net-api/Exceptions/RedmineException.cs
+++ b/src/redmine-net-api/Exceptions/RedmineException.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -15,7 +15,8 @@ limitations under the License.
*/
using System;
-using System.Globalization;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.Runtime.Serialization;
namespace Redmine.Net.Api.Exceptions
@@ -24,66 +25,59 @@ namespace Redmine.Net.Api.Exceptions
/// Thrown in case something went wrong in Redmine
///
///
+ [DebuggerDisplay($"{{{nameof(DebuggerDisplay)},nq}}")]
[Serializable]
public class RedmineException : Exception
{
///
- /// Initializes a new instance of the class.
+ ///
///
- public RedmineException()
- {
- }
+ public virtual string ErrorCode => "REDMINE-GEN-001";
///
- /// Initializes a new instance of the class.
+ ///
///
- /// The message that describes the error.
- public RedmineException(string message)
- : base(message)
- {
- }
+ public Dictionary ErrorDetails { get; private set; }
///
- /// Initializes a new instance of the class.
+ ///
///
- /// The format.
- /// The arguments.
- public RedmineException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
+ public RedmineException() { }
///
- /// Initializes a new instance of the class.
+ ///
///
- /// The error message that explains the reason for the exception.
- /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if no inner exception is specified.
- public RedmineException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
+ ///
+ public RedmineException(string message) : base(message) { }
///
- /// Initializes a new instance of the class.
+ ///
///
- /// The format.
- /// The inner exception.
- /// The arguments.
- public RedmineException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-
- #if !(NET8_0_OR_GREATER)
+ ///
+ ///
+ public RedmineException(string message, Exception innerException) : base(message, innerException) { }
+
+#if !(NET8_0_OR_GREATER)
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected RedmineException(SerializationInfo info, StreamingContext context) : base(info, context) { }
+#endif
+
///
///
///
- ///
- ///
- protected RedmineException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
+ ///
+ ///
+ ///
+ public RedmineException AddErrorDetail(string key, string value)
{
+ ErrorDetails ??= new Dictionary();
+ ErrorDetails[key] = value;
+ return this;
}
- #endif
+
+ private string DebuggerDisplay => $"[{Message}]";
}
}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineForbiddenException.cs b/src/redmine-net-api/Exceptions/RedmineForbiddenException.cs
new file mode 100644
index 00000000..33e3d53d
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineForbiddenException.cs
@@ -0,0 +1,82 @@
+ο»Ώ/*
+ 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.Constants;
+
+namespace Redmine.Net.Api.Exceptions
+{
+
+ ///
+ /// Represents an exception thrown when a Redmine API request is forbidden (HTTP 403).
+ ///
+ [Serializable]
+ public sealed class RedmineForbiddenException : RedmineApiException
+ {
+ ///
+ /// Gets the error code for this exception.
+ ///
+ public override string ErrorCode => "REDMINE-FORBIDDEN-005";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineForbiddenException()
+ : base("Access to the Redmine API resource is forbidden.", (string)null, HttpConstants.StatusCodes.Forbidden, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineForbiddenException(string message)
+ : base(message, (string)null, HttpConstants.StatusCodes.Forbidden, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and URL.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the exception.
+ /// Thrown when is null.
+ public RedmineForbiddenException(string message, string url)
+ : base(message, url, HttpConstants.StatusCodes.Forbidden, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineForbiddenException(string message, Exception innerException)
+ : base(message, (string)null, HttpConstants.StatusCodes.Forbidden, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineForbiddenException(string message, string url, Exception innerException)
+ : base(message, url, HttpConstants.StatusCodes.Forbidden, innerException)
+ { }
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineNotAcceptableException.cs b/src/redmine-net-api/Exceptions/RedmineNotAcceptableException.cs
new file mode 100644
index 00000000..8fa39cc3
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineNotAcceptableException.cs
@@ -0,0 +1,56 @@
+ο»Ώ/*
+ 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.Exceptions
+{
+ ///
+ ///
+ ///
+ [Serializable]
+ public sealed class RedmineNotAcceptableException : RedmineApiException
+ {
+ ///
+ public override string ErrorCode => "REDMINE-NOT-ACCEPTABLE-010";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineNotAcceptableException()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ public RedmineNotAcceptableException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ ///
+ public RedmineNotAcceptableException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineNotFoundException.cs b/src/redmine-net-api/Exceptions/RedmineNotFoundException.cs
new file mode 100644
index 00000000..b6a6e7bc
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineNotFoundException.cs
@@ -0,0 +1,80 @@
+ο»Ώ/*
+ 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.Constants;
+
+namespace Redmine.Net.Api.Exceptions
+{
+ ///
+ /// Thrown in case the objects requested for could not be found.
+ ///
+ ///
+ [Serializable]
+ public sealed class RedmineNotFoundException : RedmineApiException
+ {
+ ///
+ public override string ErrorCode => "REDMINE-NOTFOUND-006";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineNotFoundException()
+ : base("The requested Redmine API resource was not found.", (string)null, HttpConstants.StatusCodes.NotFound, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineNotFoundException(string message)
+ : base(message, (string)null, HttpConstants.StatusCodes.NotFound, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and URL.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that was not found.
+ /// Thrown when is null.
+ public RedmineNotFoundException(string message, string url)
+ : base(message, url, HttpConstants.StatusCodes.NotFound, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineNotFoundException(string message, Exception innerException)
+ : base(message, (string)null, HttpConstants.StatusCodes.NotFound, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that was not found.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineNotFoundException(string message, string url, Exception innerException)
+ : base(message, url, HttpConstants.StatusCodes.NotFound, innerException)
+ { }
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineOperationCanceledException.cs b/src/redmine-net-api/Exceptions/RedmineOperationCanceledException.cs
new file mode 100644
index 00000000..f3dcc39e
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineOperationCanceledException.cs
@@ -0,0 +1,85 @@
+using System;
+using Redmine.Net.Api.Http.Constants;
+
+namespace Redmine.Net.Api.Exceptions;
+
+///
+/// Represents an exception thrown when a Redmine API operation is canceled, typically due to a CancellationToken.
+///
+[Serializable]
+public sealed class RedmineOperationCanceledException : RedmineException
+{
+ ///
+ /// Gets the error code for this exception.
+ ///
+ public override string ErrorCode => "REDMINE-CANCEL-003";
+
+ ///
+ /// Gets the URL of the Redmine API resource associated with the canceled operation, if applicable.
+ ///
+ public string Url { get; private set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineOperationCanceledException()
+ : base("The Redmine API operation was canceled.")
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineOperationCanceledException(string message)
+ : base(message)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and URL.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource associated with the canceled operation.
+ /// Thrown when or is null.
+ public RedmineOperationCanceledException(string message, string url)
+ : base(message)
+ {
+ Url = url;
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineOperationCanceledException(string message, Exception innerException)
+ : base(message, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource associated with the canceled operation.
+ /// The exception that is the cause of the current exception, or null if none.
+ /// Thrown when or is null.
+ public RedmineOperationCanceledException(string message, string url, Exception innerException)
+ : base(message, innerException)
+ {
+ Url = url;
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource associated with the canceled operation.
+ /// The exception that is the cause of the current exception, or null if none.
+ /// Thrown when or is null.
+ public RedmineOperationCanceledException(string message, Uri url, Exception innerException)
+ : base(message, innerException)
+ {
+ Url = url?.ToString();
+ }
+}
\ 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..e864ef40
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineSerializationException.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace Redmine.Net.Api.Exceptions;
+
+///
+/// Represents an exception thrown when a serialization or deserialization error occurs in the Redmine API client.
+///
+[Serializable]
+public sealed class RedmineSerializationException : RedmineException
+{
+ ///
+ public override string ErrorCode => "REDMINE-SERIALIZATION-008";
+
+ ///
+ /// Gets the name of the parameter that caused the serialization or deserialization error, if applicable.
+ ///
+ public string ParamName { get; private set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineSerializationException()
+ : base("A serialization or deserialization error occurred in the Redmine API client.")
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineSerializationException(string message)
+ : base(message)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and parameter name.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The name of the parameter that caused the serialization or deserialization error.
+ /// Thrown when or is null.
+ public RedmineSerializationException(string message, string paramName)
+ : base(message)
+ {
+ ParamName = paramName ?? throw new ArgumentNullException(nameof(paramName));
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineSerializationException(string message, Exception innerException)
+ : base(message, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, parameter name, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The name of the parameter that caused the serialization or deserialization error.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when , , or is null.
+ public RedmineSerializationException(string message, string paramName, Exception innerException)
+ : base(message, innerException)
+ {
+ ParamName = paramName ?? throw new ArgumentNullException(nameof(paramName));
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs b/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs
index a919fd96..038a44df 100644
--- a/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs
+++ b/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs
@@ -1,5 +1,5 @@
ο»Ώ/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -15,77 +15,76 @@ limitations under the License.
*/
using System;
-using System.Globalization;
-using System.Runtime.Serialization;
+using Redmine.Net.Api.Http.Constants;
namespace Redmine.Net.Api.Exceptions
{
///
+ /// Represents an exception thrown when a Redmine API request times out (HTTP 408).
///
- ///
[Serializable]
- public sealed class RedmineTimeoutException : RedmineException
+ public sealed class RedmineTimeoutException : RedmineApiException
{
+ ///
+ public override string ErrorCode => "REDMINE-TIMEOUT-004";
+
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
public RedmineTimeoutException()
+ : base("The Redmine API request timed out.", (string)null, HttpConstants.StatusCodes.RequestTimeout, null)
{
}
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class with a specified error message.
///
- /// The message that describes the error.
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
public RedmineTimeoutException(string message)
- : base(message)
- {
- }
+ : base(message, (string)null, HttpConstants.StatusCodes.RequestTimeout, null)
+ { }
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class with a specified error message and URL.
///
- /// The format.
- /// The arguments.
- public RedmineTimeoutException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that timed out.
+ /// Thrown when or is null.
+ public RedmineTimeoutException(string message, string url)
+ : base(message, url, HttpConstants.StatusCodes.RequestTimeout, null)
+ { }
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class with a specified error message and inner 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 (Nothing in
- /// Visual Basic) if no inner exception is specified.
- ///
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
public RedmineTimeoutException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
+ : base(message, (string)null, HttpConstants.StatusCodes.RequestTimeout, innerException)
+ { }
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
///
- /// The format.
- /// The inner exception.
- /// The arguments.
- public RedmineTimeoutException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that timed out.
+ /// The exception that is the cause of the current exception, or null if none.
+ /// Thrown when or is null.
+ public RedmineTimeoutException(string message, string url, Exception innerException)
+ : base(message, url, HttpConstants.StatusCodes.RequestTimeout, innerException)
+ { }
-#if !(NET8_0_OR_GREATER)
///
- ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
///
- ///
- ///
- private RedmineTimeoutException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that timed out.
+ /// The exception that is the cause of the current exception, or null if none.
+ /// Thrown when or is null.
+ public RedmineTimeoutException(string message, Uri url, Exception innerException)
+ : base(message, url?.ToString(), HttpConstants.StatusCodes.RequestTimeout, innerException)
+ { }
}
}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineUnauthorizedException.cs b/src/redmine-net-api/Exceptions/RedmineUnauthorizedException.cs
new file mode 100644
index 00000000..824d7727
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineUnauthorizedException.cs
@@ -0,0 +1,79 @@
+ο»Ώ/*
+ 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.Constants;
+
+namespace Redmine.Net.Api.Exceptions
+{
+ ///
+ /// Represents an exception thrown when a Redmine API request is unauthorized (HTTP 401).
+ ///
+ [Serializable]
+ public sealed class RedmineUnauthorizedException : RedmineApiException
+ {
+ ///
+ public override string ErrorCode => "REDMINE-UNAUTHORIZED-007";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineUnauthorizedException()
+ : base("The Redmine API request is unauthorized.", (string)null, HttpConstants.StatusCodes.Unauthorized, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineUnauthorizedException(string message)
+ : base(message, (string)null, HttpConstants.StatusCodes.Unauthorized, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and URL.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the unauthorized access.
+ /// Thrown when is null.
+ public RedmineUnauthorizedException(string message, string url)
+ : base(message, url, HttpConstants.StatusCodes.Unauthorized, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineUnauthorizedException(string message, Exception innerException)
+ : base(message, (string)null, HttpConstants.StatusCodes.Unauthorized, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the unauthorized access.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineUnauthorizedException(string message, string url, Exception innerException)
+ : base(message, url, HttpConstants.StatusCodes.Unauthorized, innerException)
+ { }
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineUnprocessableEntityException.cs b/src/redmine-net-api/Exceptions/RedmineUnprocessableEntityException.cs
new file mode 100644
index 00000000..c37dd063
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineUnprocessableEntityException.cs
@@ -0,0 +1,63 @@
+using System;
+using Redmine.Net.Api.Http.Constants;
+
+namespace Redmine.Net.Api.Exceptions;
+
+///
+/// Represents an exception thrown when a Redmine API request cannot be processed due to semantic errors (HTTP 422).
+///
+[Serializable]
+public class RedmineUnprocessableEntityException : RedmineApiException
+{
+ ///
+ /// Gets the error code for this exception.
+ ///
+ public override string ErrorCode => "REDMINE-UNPROCESSABLE-009";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RedmineUnprocessableEntityException()
+ : base("The Redmine API request cannot be processed due to invalid data.", (string)null, HttpConstants.StatusCodes.UnprocessableEntity, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// Thrown when is null.
+ public RedmineUnprocessableEntityException(string message)
+ : base(message, (string)null, HttpConstants.StatusCodes.UnprocessableEntity, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and URL.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the error.
+ /// Thrown when is null.
+ public RedmineUnprocessableEntityException(string message, string url)
+ : base(message, url, HttpConstants.StatusCodes.UnprocessableEntity, null)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineUnprocessableEntityException(string message, Exception innerException)
+ : base(message, (string)null, HttpConstants.StatusCodes.UnprocessableEntity, innerException)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, URL, and inner exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The URL of the Redmine API resource that caused the error.
+ /// The exception that is the cause of the current exception.
+ /// Thrown when or is null.
+ public RedmineUnprocessableEntityException(string message, string url, Exception innerException)
+ : base(message, url, HttpConstants.StatusCodes.UnprocessableEntity, innerException)
+ { }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/UnauthorizedException.cs b/src/redmine-net-api/Exceptions/UnauthorizedException.cs
deleted file mode 100644
index c77c37f8..00000000
--- a/src/redmine-net-api/Exceptions/UnauthorizedException.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-using System.Runtime.Serialization;
-
-namespace Redmine.Net.Api.Exceptions
-{
- ///
- /// Thrown in case something went wrong while trying to login.
- ///
- ///
- [Serializable]
- public sealed class UnauthorizedException : RedmineException
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public UnauthorizedException()
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The message that describes the error.
- public UnauthorizedException(string message)
- : base(message)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The format.
- /// The arguments.
- public UnauthorizedException(string format, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args))
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The error message that explains the reason for the exception.
- ///
- /// The exception that is the cause of the current exception, or a null reference (Nothing in
- /// Visual Basic) if no inner exception is specified.
- ///
- public UnauthorizedException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The format.
- /// The inner exception.
- /// The arguments.
- public UnauthorizedException(string format, Exception innerException, params object[] args)
- : base(string.Format(CultureInfo.InvariantCulture,format, args), innerException)
- {
- }
-#if !(NET8_0_OR_GREATER)
- ///
- ///
- ///
- ///
- ///
- ///
- private UnauthorizedException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
-#endif
- }
-}
\ 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 5a14bfe3..00000000
--- a/src/redmine-net-api/Extensions/CollectionExtensions.cs
+++ /dev/null
@@ -1,111 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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;
-
-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) 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((T) item.Clone());
- }
-
- 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..848fdc2e
--- /dev/null
+++ b/src/redmine-net-api/Extensions/EnumExtensions.cs
@@ -0,0 +1,112 @@
+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..b461a8e4
--- /dev/null
+++ b/src/redmine-net-api/Extensions/IdentifiableNameExtensions.cs
@@ -0,0 +1,84 @@
+using System;
+using Redmine.Net.Api.Exceptions;
+using Redmine.Net.Api.Types;
+using Version = Redmine.Net.Api.Types.Version;
+
+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 ArgumentException("Value must be greater than zero", nameof(val));
+ }
+
+ 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 ArgumentException("Value must be greater than zero", nameof(val));
+ }
+
+ return new IssueStatus(val, null);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/IntExtensions.cs b/src/redmine-net-api/Extensions/IntExtensions.cs
index 5f838547..fa0e57aa 100644
--- a/src/redmine-net-api/Extensions/IntExtensions.cs
+++ b/src/redmine-net-api/Extensions/IntExtensions.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
diff --git a/src/redmine-net-api/Extensions/ListExtensions.cs b/src/redmine-net-api/Extensions/ListExtensions.cs
new file mode 100755
index 00000000..1064b3f4
--- /dev/null
+++ b/src/redmine-net-api/Extensions/ListExtensions.cs
@@ -0,0 +1,77 @@
+ο»Ώ/*
+ 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 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 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/RedmineManagerAsyncExtensionsObsolete.cs b/src/redmine-net-api/Extensions/RedmineManagerAsyncExtensionsObsolete.cs
deleted file mode 100644
index dd605bef..00000000
--- a/src/redmine-net-api/Extensions/RedmineManagerAsyncExtensionsObsolete.cs
+++ /dev/null
@@ -1,366 +0,0 @@
-ο»Ώ/*
-Copyright 2011 - 2023 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- public static async Task UploadFileAsync(this RedmineManager redmineManager, byte[] data)
- {
- var requestOptions = RedmineManagerExtensions.CreateRequestOptions();
- return await redmineManager.UploadFileAsync(data, requestOptions).ConfigureAwait(false);
- }
-
- ///
- /// Downloads the file asynchronous.
- ///
- /// The redmine manager.
- /// The address.
- ///
- [Obsolete("Use DownloadFileAsync 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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)]
- 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);
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- [Obsolete(RedmineConstants.OBSOLETE_TEXT)]
- public static async Task> SearchAsync(this RedmineManager redmineManager, string q, int limit = RedmineManager.DEFAULT_PAGE_SIZE_VALUE, int offset = 0, SearchFilterBuilder searchFilter = null)
- {
- return await RedmineManagerExtensions.SearchAsync(redmineManager, q, limit, offset, searchFilter).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 75a778df..71ebcf13 100644
--- a/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs
+++ b/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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;
@@ -34,34 +36,125 @@ namespace Redmine.Net.Api.Extensions
///
public static class RedmineManagerExtensions
{
- ///
- ///
+ ///
+ /// Archives a project in Redmine based on the specified project identifier.
///
- ///
- ///
- ///
- ///
- 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 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.ProjectNews(projectIdentifier);
+ var uri = redmineManager.RedmineApiUrls.ProjectArchive(projectIdentifier);
- var escapedUri = Uri.EscapeDataString(uri);
+ redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions);
+ }
+
+ ///
+ /// Unarchives a project in Redmine based on the specified project identifier.
+ ///
+ /// 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);
+
+ redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions);
+ }
+
+ ///
+ /// Reopens a previously closed project in Redmine based on the specified project identifier.
+ ///
+ /// 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);
+
+ redmineManager.ApiClient.Update(uri, string.Empty ,requestOptions);
+ }
+
+ ///
+ /// Closes a project in Redmine based on the specified project identifier.
+ ///
+ /// 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);
+
+ redmineManager.ApiClient.Update(uri,string.Empty, requestOptions);
+ }
+
+ ///
+ /// Adds a related issue to a project repository in Redmine based on the specified parameters.
+ ///
+ /// 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);
+
+ _ = redmineManager.ApiClient.Create(uri,string.Empty, requestOptions);
+ }
+
+ ///
+ /// Removes a related issue from the specified repository revision of a project in Redmine.
+ ///
+ /// 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 response = redmineManager.GetPaginatedObjects(escapedUri, requestOptions);
+ _ = redmineManager.ApiClient.Delete(uri, requestOptions);
+ }
+
+ ///
+ /// Retrieves a paginated list of news for a specific project in Redmine.
+ ///
+ /// 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 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)
{
@@ -75,45 +168,45 @@ 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);
- var response = redmineManager.GetPaginatedObjects(uri, requestOptions);
+ var response = redmineManager.GetPaginatedInternal(uri, requestOptions);
return response;
}
///
- ///
+ /// 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);
- var response = redmineManager.GetPaginatedObjects(uri, requestOptions);
+ var response = redmineManager.GetPaginatedInternal(uri, requestOptions);
return response;
}
@@ -121,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();
@@ -132,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();
@@ -147,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);
@@ -163,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);
@@ -193,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);
@@ -226,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);
}
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static WikiPage CreateWikiPage(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null)
+ /// Creates a new wiki page within a specified project in Redmine.
+ ///
+ /// 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);
@@ -251,47 +350,45 @@ 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);
- var response = redmineManager.GetObjects(uri, requestOptions);
+ var response = redmineManager.GetInternal(uri, requestOptions);
return response;
}
@@ -300,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);
}
///
@@ -319,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
@@ -329,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);
}
@@ -350,7 +445,11 @@ public static PagedResults Search(this RedmineManager redmineManager, st
{
var parameters = CreateSearchParameters(q, limit, offset, searchFilter);
- var response = redmineManager.GetPaginatedObjects(parameters);
+ var response = redmineManager.GetPaginated(new RequestOptions
+ {
+ QueryString = parameters,
+ ImpersonateUser = impersonateUserName
+ });
return response;
}
@@ -365,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;
@@ -374,21 +473,108 @@ private static NameValueCollection CreateSearchParameters(string q, int limit, i
#if !(NET20)
+ ///
+ /// Archives the project asynchronously
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Unarchives the project asynchronously
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Closes the project asynchronously
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.UpdateAsync(uri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reopens the project asynchronously
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.UpdateAsync(uri, string.Empty, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.CreateAsync(uri, string.Empty ,requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
///
///
///
///
///
- ///
+ ///
+ ///
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// 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);
}
@@ -399,7 +585,7 @@ public static async Task> GetProjectNewsAsync(this RedmineMan
///
///
///
- ///
+ /// Additional request options to include in the API call.
///
///
///
@@ -417,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);
}
@@ -431,7 +615,7 @@ public static async Task AddProjectNewsAsync(this RedmineManager redmineMa
///
///
///
- ///
+ /// Additional request options to include in the API call.
///
///
///
@@ -449,7 +633,7 @@ public static async Task> GetProjectMembershipsA
///
///
///
- ///
+ /// Additional request options to include in the API call.
///
///
///
@@ -461,7 +645,6 @@ public static async Task> GetProjectFilesAsync(this RedmineMa
return response.DeserializeToPagedResults(redmineManager.Serializer);
}
-
///
///
@@ -473,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)
@@ -502,41 +690,60 @@ public static async Task GetCurrentUserAsync(this RedmineManager redmineMa
}
///
- /// Creates the or update wiki page asynchronous.
+ /// 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.
///
/// The redmine manager.
/// 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 escapedUri = Uri.EscapeDataString(url);
+ var path = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName);
- 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);
}
///
- /// Creates the or update wiki page asynchronous.
+ /// Creates or updates wiki page asynchronous.
///
/// The redmine manager.
/// 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)
@@ -550,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);
}
///
@@ -561,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);
}
///
@@ -579,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.
///
///
@@ -587,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);
}
@@ -601,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)
@@ -610,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;
}
///
@@ -619,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);
@@ -639,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);
}
@@ -655,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);
@@ -673,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/SemaphoreSlimExtensions.cs b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs
index a47dbb9b..016dd51f 100644
--- a/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs
+++ b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs
@@ -1,5 +1,5 @@
/*
-Copyright 2011 - 2023 Adrian Popescu
+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.
diff --git a/src/redmine-net-api/Extensions/StringExtensions.cs b/src/redmine-net-api/Extensions/StringExtensions.cs
index c02836e7..a881f14d 100644
--- a/src/redmine-net-api/Extensions/StringExtensions.cs
+++ b/src/redmine-net-api/Extensions/StringExtensions.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -18,6 +18,7 @@ limitations under the License.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Security;
+using System.Text.RegularExpressions;
namespace Redmine.Net.Api.Extensions
{
@@ -27,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)
@@ -38,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;
}
@@ -50,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)
@@ -98,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))
@@ -127,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
@@ -151,10 +169,35 @@ 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 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())
+ {
+ return input;
+ }
+
+ #if NET6_0_OR_GREATER
+ input = input.ReplaceLineEndings(replacement);
+ #else
+ input = Regex.Replace(input, $"{CRLR}|{CR}|{LR}", replacement);
+ #endif
+ return input;
+ }
}
}
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/TaskExtensions.cs b/src/redmine-net-api/Extensions/TaskExtensions.cs
index c74b6d4d..dd182846 100644
--- a/src/redmine-net-api/Extensions/TaskExtensions.cs
+++ b/src/redmine-net-api/Extensions/TaskExtensions.cs
@@ -1,5 +1,5 @@
/*
-Copyright 2011 - 2023 Adrian Popescu
+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.
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..6f2f7e10
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.Async.cs
@@ -0,0 +1,155 @@
+#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.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Redmine.Net.Api.Exceptions;
+using Redmine.Net.Api.Extensions;
+using Redmine.Net.Api.Http.Constants;
+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);
+ var response = await SendAsync(requestMessage, progress: progress, cancellationToken: cancellationToken).ConfigureAwait(false);
+ return response;
+ }
+
+ 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))
+ {
+ var url = requestMessage.RequestUri?.ToString();
+ var message = httpResponseMessage.ReasonPhrase;
+
+ throw statusCode switch
+ {
+ HttpConstants.StatusCodes.NotFound => new RedmineNotFoundException(message, url),
+ HttpConstants.StatusCodes.Unauthorized => new RedmineUnauthorizedException(message, url),
+ HttpConstants.StatusCodes.Forbidden => new RedmineForbiddenException(message, url),
+ HttpConstants.StatusCodes.UnprocessableEntity => RedmineExceptionHelper.CreateUnprocessableEntityException(url, stream, null, Serializer),
+ HttpConstants.StatusCodes.NotAcceptable => new RedmineNotAcceptableException(message),
+ _ => new RedmineApiException(message, url, statusCode),
+ };
+ }
+ }
+ }
+ catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
+ {
+ throw new RedmineOperationCanceledException(ex.Message, requestMessage.RequestUri, ex);
+ }
+ catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException tex)
+ {
+ throw new RedmineTimeoutException(tex.Message, requestMessage.RequestUri, tex);
+ }
+ catch (TaskCanceledException tcex) when (cancellationToken.IsCancellationRequested)
+ {
+ throw new RedmineOperationCanceledException(tcex.Message, requestMessage.RequestUri, tcex);
+ }
+ catch (TaskCanceledException tce)
+ {
+ throw new RedmineTimeoutException(tce.Message, requestMessage.RequestUri, tce);
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new RedmineApiException(ex.Message, requestMessage.RequestUri, HttpConstants.StatusCodes.Unknown, ex);
+ }
+ catch (Exception ex) when (ex is not RedmineException)
+ {
+ throw new RedmineApiException(ex.Message, requestMessage.RequestUri, HttpConstants.StatusCodes.Unknown, ex);
+ }
+ }
+
+ 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);
+ ClientHelper.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..c785145c
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/HttpClient/InternalRedmineApiHttpClient.cs
@@ -0,0 +1,188 @@
+#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 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))
+ {
+ var response = Send(requestMessage, progress);
+ 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 (!requestOptions.Accept.IsNullOrWhiteSpace())
+ {
+ httpRequest.Headers.Accept.ParseAdd(requestOptions.Accept);
+ }
+
+ if (!requestOptions.UserAgent.IsNullOrWhiteSpace())
+ {
+ httpRequest.Headers.UserAgent.ParseAdd(requestOptions.UserAgent);
+ }
+
+ if (requestOptions.Headers != null)
+ {
+ foreach (var header in requestOptions.Headers)
+ {
+ httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+ }
+ }
+
+ if (content == null)
+ {
+ return httpRequest;
+ }
+
+ httpRequest.Content = content;
+ if (requestOptions?.ContentType != null)
+ {
+ content.Headers.ContentType = new MediaTypeHeaderValue(requestOptions.ContentType);
+ }
+
+ 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..94277181
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.Async.cs
@@ -0,0 +1,144 @@
+/*
+ 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)
+ {
+ LogRequest(verb, address, requestOptions);
+
+ var response = await SendAsync(CreateRequestMessage(address, verb, Serializer, requestOptions, content as RedmineApiRequestContent), progress, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ LogResponse((int)response.StatusCode);
+
+ return response;
+ }
+
+ 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); };
+ }
+
+ if (requestMessage.QueryString != null)
+ {
+ webClient.QueryString = requestMessage.QueryString;
+ }
+
+ webClient.ApplyHeaders(requestMessage, Credentials);
+
+ 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;
+ statusCode = webClient.GetStatusCode();
+ }
+ catch (WebException ex) when (ex.Status == WebExceptionStatus.RequestCanceled)
+ {
+ throw new RedmineOperationCanceledException(ex.Message, requestMessage.RequestUri, ex);
+ }
+ catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
+ {
+ throw new RedmineTimeoutException(ex.Message, requestMessage.RequestUri, ex);
+ }
+ catch (WebException webException)when (webException.Status == WebExceptionStatus.ProtocolError)
+ {
+ HandleResponseException(webException, requestMessage.RequestUri, Serializer);
+ }
+ catch (OperationCanceledException ex)
+ {
+ throw new RedmineOperationCanceledException(ex.Message, requestMessage.RequestUri, ex);
+ }
+ catch (Exception ex)
+ {
+ throw new RedmineApiException(ex.Message, requestMessage.RequestUri, null, ex);
+ }
+ 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..b4eb7a7b
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/InternalRedmineApiWebClient.cs
@@ -0,0 +1,220 @@
+/*
+ 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 Redmine.Net.Api.Exceptions;
+using Redmine.Net.Api.Extensions;
+using Redmine.Net.Api.Http.Constants;
+using Redmine.Net.Api.Http.Helpers;
+using Redmine.Net.Api.Http.Messages;
+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, Serializer, requestOptions, content as RedmineApiRequestContent);
+
+ var responseMessage = Send(requestMessage, progress);
+
+ return responseMessage;
+ }
+
+ private static RedmineApiRequest CreateRequestMessage(string address, string verb, IRedmineSerializer serializer, 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 (!requestOptions.Accept.IsNullOrWhiteSpace())
+ {
+ req.Accept = requestOptions.Accept;
+ }
+
+ if (requestOptions.Headers != null)
+ {
+ req.Headers = requestOptions.Headers;
+ }
+
+ if (!requestOptions.UserAgent.IsNullOrWhiteSpace())
+ {
+ req.UserAgent = requestOptions.UserAgent;
+ }
+ }
+
+ if (content != null)
+ {
+ req.Content = content;
+ req.ContentType = content.ContentType;
+ }
+ else
+ {
+ req.ContentType = serializer.ContentType;
+ }
+
+ 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();
+
+ if (requestMessage.QueryString != null)
+ {
+ webClient.QueryString = requestMessage.QueryString;
+ }
+
+ webClient.ApplyHeaders(requestMessage, Credentials);
+
+ if (IsGetOrDownload(requestMessage.Method))
+ {
+ response = requestMessage.Method == HttpConstants.HttpVerbs.DOWNLOAD
+ ? webClient.DownloadWithProgress(requestMessage.RequestUri, 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;
+ statusCode = webClient.GetStatusCode();
+ }
+ catch (WebException webException) when (webException.Status == WebExceptionStatus.ProtocolError)
+ {
+ HandleResponseException(webException, requestMessage.RequestUri, Serializer);
+ }
+ catch (WebException webException)
+ {
+ if (webException.Status == WebExceptionStatus.RequestCanceled)
+ {
+ throw new RedmineOperationCanceledException(webException.Message, requestMessage.RequestUri, webException.InnerException);
+ }
+
+ if (webException.Status == WebExceptionStatus.Timeout)
+ {
+ throw new RedmineTimeoutException(webException.Message, requestMessage.RequestUri, webException.InnerException);
+ }
+
+ var errStatusCode = GetExceptionStatusCode(webException);
+ throw new RedmineApiException(webException.Message, requestMessage.RequestUri, errStatusCode, webException.InnerException);
+ }
+ catch (Exception ex)
+ {
+ throw new RedmineApiException(ex.Message, requestMessage.RequestUri, HttpConstants.StatusCodes.Unknown, ex.InnerException);
+ }
+ finally
+ {
+ webClient?.Dispose();
+ }
+
+ return new RedmineApiResponse()
+ {
+ Headers = responseHeaders,
+ Content = response,
+ StatusCode = statusCode ?? HttpStatusCode.OK,
+ };
+ }
+
+ private static void HandleResponseException(WebException exception, string url, IRedmineSerializer serializer)
+ {
+ var innerException = exception.InnerException ?? exception;
+
+ if (exception.Response == null)
+ {
+ throw new RedmineApiException(exception.Message, url, null, innerException);
+ }
+
+ var statusCode = GetExceptionStatusCode(exception);
+
+ using var responseStream = exception.Response.GetResponseStream();
+ throw statusCode switch
+ {
+ HttpConstants.StatusCodes.NotFound => new RedmineNotFoundException(exception.Message, url, innerException),
+ HttpConstants.StatusCodes.Unauthorized => new RedmineUnauthorizedException(exception.Message, url, innerException),
+ HttpConstants.StatusCodes.Forbidden => new RedmineForbiddenException(exception.Message, url, innerException),
+ HttpConstants.StatusCodes.UnprocessableEntity => RedmineExceptionHelper.CreateUnprocessableEntityException(url, responseStream, innerException, serializer),
+ HttpConstants.StatusCodes.NotAcceptable => new RedmineNotAcceptableException(exception.Message, innerException),
+ _ => new RedmineApiException(exception.Message, url, statusCode, innerException),
+ };
+ }
+
+ private static int? GetExceptionStatusCode(WebException webException)
+ {
+ var statusCode = webException.Response is HttpWebResponse httpResponse
+ ? (int)httpResponse.StatusCode
+ : HttpConstants.StatusCodes.Unknown;
+ return statusCode;
+ }
+ }
+}
diff --git a/src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs b/src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs
new file mode 100644
index 00000000..fabd7219
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/InternalWebClient.cs
@@ -0,0 +1,135 @@
+/*
+ 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.Exceptions;
+using Redmine.Net.Api.Options;
+
+namespace Redmine.Net.Api.Http.Clients.WebClient;
+
+internal sealed class InternalWebClient : System.Net.WebClient
+{
+ private readonly RedmineWebClientOptions _webClientOptions;
+
+ #pragma warning disable SYSLIB0014
+ public InternalWebClient(RedmineManagerOptions redmineManagerOptions)
+ {
+ _webClientOptions = redmineManagerOptions.WebClientOptions;
+ BaseAddress = redmineManagerOptions.BaseAddress.ToString();
+ }
+ #pragma warning restore SYSLIB0014
+
+ protected override WebRequest GetWebRequest(Uri address)
+ {
+ try
+ {
+ var webRequest = base.GetWebRequest(address);
+
+ if (webRequest is not HttpWebRequest httpWebRequest)
+ {
+ return base.GetWebRequest(address);
+ }
+
+ httpWebRequest.UserAgent = _webClientOptions.UserAgent;
+
+ AssignIfHasValue(_webClientOptions.DecompressionFormat, value => httpWebRequest.AutomaticDecompression = value);
+
+ AssignIfHasValue(_webClientOptions.AutoRedirect, value => httpWebRequest.AllowAutoRedirect = value);
+
+ AssignIfHasValue(_webClientOptions.MaxAutomaticRedirections, value => httpWebRequest.MaximumAutomaticRedirections = value);
+
+ AssignIfHasValue(_webClientOptions.KeepAlive, value => httpWebRequest.KeepAlive = value);
+
+ AssignIfHasValue(_webClientOptions.Timeout, value => httpWebRequest.Timeout = (int) value.TotalMilliseconds);
+
+ AssignIfHasValue(_webClientOptions.PreAuthenticate, value => httpWebRequest.PreAuthenticate = value);
+
+ AssignIfHasValue(_webClientOptions.UseCookies, value => httpWebRequest.CookieContainer = _webClientOptions.CookieContainer);
+
+ AssignIfHasValue(_webClientOptions.UnsafeAuthenticatedConnectionSharing, value => httpWebRequest.UnsafeAuthenticatedConnectionSharing = value);
+
+ AssignIfHasValue(_webClientOptions.MaxResponseContentBufferSize, value => { });
+
+ if (_webClientOptions.DefaultHeaders != null)
+ {
+ httpWebRequest.Headers = new WebHeaderCollection();
+ foreach (var defaultHeader in _webClientOptions.DefaultHeaders)
+ {
+ httpWebRequest.Headers.Add(defaultHeader.Key, defaultHeader.Value);
+ }
+ }
+
+ httpWebRequest.CachePolicy = _webClientOptions.RequestCachePolicy;
+
+ httpWebRequest.Proxy = _webClientOptions.Proxy;
+
+ httpWebRequest.Credentials = _webClientOptions.Credentials;
+
+ #if NET40_OR_GREATER || NET
+ if (_webClientOptions.ClientCertificates != null)
+ {
+ httpWebRequest.ClientCertificates = _webClientOptions.ClientCertificates;
+ }
+ #endif
+
+ #if (NET45_OR_GREATER || NET)
+ httpWebRequest.ServerCertificateValidationCallback = _webClientOptions.ServerCertificateValidationCallback;
+ #endif
+
+ if (_webClientOptions.ProtocolVersion != null)
+ {
+ httpWebRequest.ProtocolVersion = _webClientOptions.ProtocolVersion;
+ }
+
+ return httpWebRequest;
+ }
+ catch (Exception webException)
+ {
+ 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
+ {
+ if (nullableValue.HasValue)
+ {
+ assignAction(nullableValue.Value);
+ }
+ }
+}
\ No newline at end of file
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..65291f8e
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/RedmineWebClientOptions.cs
@@ -0,0 +1,82 @@
+/*
+ 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
+
+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..3a831655
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/WebClientExtensions.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Globalization;
+using System.Net;
+using Redmine.Net.Api.Authentication;
+using Redmine.Net.Api.Extensions;
+using Redmine.Net.Api.Http.Helpers;
+using Redmine.Net.Api.Http.Messages;
+
+namespace Redmine.Net.Api.Http.Clients.WebClient;
+
+internal static class WebClientExtensions
+{
+ public static void ApplyHeaders(this System.Net.WebClient client, RedmineApiRequest request, IRedmineAuthentication authentication)
+ {
+ switch (authentication)
+ {
+ case RedmineApiKeyAuthentication:
+ client.Headers.Add(RedmineConstants.API_KEY_AUTHORIZATION_HEADER_KEY, authentication.Token);
+ break;
+ case RedmineBasicAuthentication:
+ client.Headers.Add(RedmineConstants.AUTHORIZATION_HEADER_KEY, authentication.Token);
+ break;
+ }
+
+ client.Headers.Add(RedmineConstants.CONTENT_TYPE_HEADER_KEY, request.ContentType);
+
+ if (!request.UserAgent.IsNullOrWhiteSpace())
+ {
+ client.Headers.Add(RedmineConstants.USER_AGENT_HEADER_KEY, request.UserAgent);
+ }
+
+ if (!request.ImpersonateUser.IsNullOrWhiteSpace())
+ {
+ client.Headers.Add(RedmineConstants.IMPERSONATE_HEADER_KEY, request.ImpersonateUser);
+ }
+
+ if (request.Headers is not { Count: > 0 })
+ {
+ return;
+ }
+
+ foreach (var header in request.Headers)
+ {
+ client.Headers.Add(header.Key, header.Value);
+ }
+
+ if (!request.Accept.IsNullOrWhiteSpace())
+ {
+ client.Headers.Add(HttpRequestHeader.Accept, request.Accept);
+ }
+ }
+
+ internal static byte[] DownloadWithProgress(this System.Net.WebClient webClient, string url, 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;
+
+ ClientHelper.ReportProgress(progress, contentLength, totalBytesRead);
+ }
+ }
+ }
+ else
+ {
+ data = webClient.DownloadData(url);
+ progress?.Report(100);
+ }
+
+ return data;
+ }
+
+ internal static long GetContentLength(this System.Net.WebClient webClient)
+ {
+ var total = -1L;
+ if (webClient.ResponseHeaders == null)
+ {
+ return total;
+ }
+
+ var contentLengthAsString = webClient.ResponseHeaders[HttpRequestHeader.ContentLength];
+ if (!string.IsNullOrEmpty(contentLengthAsString))
+ {
+ total = Convert.ToInt64(contentLengthAsString, CultureInfo.InvariantCulture);
+ }
+
+ return total;
+ }
+
+ internal static HttpStatusCode? GetStatusCode(this System.Net.WebClient webClient)
+ {
+ if (webClient is InternalWebClient iwc)
+ {
+ return iwc.StatusCode;
+ }
+
+ return null;
+ }
+}
\ 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..10af757d
--- /dev/null
+++ b/src/redmine-net-api/Http/Clients/WebClient/WebClientProvider.cs
@@ -0,0 +1,65 @@
+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..77ad1f4c
--- /dev/null
+++ b/src/redmine-net-api/Http/Constants/HttpConstants.cs
@@ -0,0 +1,108 @@
+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 ClientCloseRequest = 499;
+ public const int InternalServerError = 500;
+ public const int BadGateway = 502;
+ public const int ServiceUnavailable = 503;
+ public const int GatewayTimeout = 504;
+ public const int Unknown = 999;
+ }
+
+ ///
+ /// 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..5199775f
--- /dev/null
+++ b/src/redmine-net-api/Http/Extensions/NameValueCollectionExtensions.cs
@@ -0,0 +1,267 @@
+ο»Ώ/*
+ 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.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/ClientHelper.cs b/src/redmine-net-api/Http/Helpers/ClientHelper.cs
new file mode 100644
index 00000000..6ffbef3b
--- /dev/null
+++ b/src/redmine-net-api/Http/Helpers/ClientHelper.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Redmine.Net.Api.Http.Helpers;
+
+internal static class ClientHelper
+{
+ internal static void ReportProgress(IProgressprogress, long total, long bytesRead)
+ {
+ if (progress == null || total <= 0)
+ {
+ return;
+ }
+ var percent = (int)(bytesRead * 100L / total);
+ progress.Report(percent);
+ }
+}
\ 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..2d51e16e
--- /dev/null
+++ b/src/redmine-net-api/Http/Helpers/RedmineExceptionHelper.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+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
+{
+ ///
+ /// 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 RedmineApiException with details about the validation errors.
+ internal static RedmineApiException CreateUnprocessableEntityException(string uri, Stream responseStream, Exception inner, IRedmineSerializer serializer)
+ {
+ var errors = GetRedmineErrors(responseStream, serializer);
+
+ if (errors is null)
+ {
+ return new RedmineUnprocessableEntityException(HttpConstants.ErrorMessages.UnprocessableEntity ,uri, inner);
+ }
+
+ var message = BuildUnprocessableContentMessage(errors);
+
+ return new RedmineUnprocessableEntityException(message, url: uri, inner);
+ }
+
+ internal static RedmineApiException CreateUnprocessableEntityException(string url, string content, IRedmineSerializer serializer)
+ {
+ var paged = serializer.DeserializeToPagedResults(content);
+
+ var message = BuildUnprocessableContentMessage(paged.Items);
+
+ return new RedmineApiException(message: message, url: url, httpStatusCode: HttpConstants.StatusCodes.UnprocessableEntity, innerException: null);
+ }
+
+ internal static string BuildUnprocessableContentMessage(List errors)
+ {
+ 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 sb.ToString();
+ }
+
+ ///
+ /// 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)
+ {
+ using var reader = new StreamReader(responseStream);
+ var content = reader.ReadToEnd();
+ if (content.IsNullOrWhiteSpace())
+ {
+ return null;
+ }
+
+ var paged = serializer.DeserializeToPagedResults(content);
+ return paged.Items;
+ }
+ }
+}
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..6adee604
--- /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.
+ internal 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..ed3b23c9
--- /dev/null
+++ b/src/redmine-net-api/Http/IRedmineApiClient.cs
@@ -0,0 +1,73 @@
+/*
+ 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
+
+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..df00aaef
--- /dev/null
+++ b/src/redmine-net-api/Http/IRedmineApiClientOptions.cs
@@ -0,0 +1,147 @@
+/*
+ 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.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/RequestOptions.cs b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs
similarity index 60%
rename from src/redmine-net-api/Net/RequestOptions.cs
rename to src/redmine-net-api/Http/Messages/RedmineApiRequest.cs
index 1b514c8a..bce2a22f 100644
--- a/src/redmine-net-api/Net/RequestOptions.cs
+++ b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -14,15 +14,30 @@ 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.Http.Clients.WebClient;
+using Redmine.Net.Api.Http.Constants;
-namespace Redmine.Net.Api.Net;
+namespace Redmine.Net.Api.Http.Messages;
-///
-///
-///
-public sealed class RequestOptions
+internal sealed class RedmineApiRequest
{
+ ///
+ ///
+ ///
+ public RedmineApiRequestContent Content { get; set; }
+
+ ///
+ ///
+ ///
+ public string Method { get; set; } = HttpConstants.HttpVerbs.GET;
+
+ ///
+ ///
+ ///
+ public string RequestUri { get; set; }
+
///
///
///
@@ -31,10 +46,12 @@ public sealed class RequestOptions
///
///
public string ImpersonateUser { get; set; }
+
///
///
///
public string ContentType { get; set; }
+
///
///
///
@@ -43,4 +60,9 @@ public sealed class RequestOptions
///
///
public string UserAgent { get; set; }
+
+ ///
+ ///
+ ///
+ public Dictionary Headers { get; set; }
}
\ No newline at end of file
diff --git a/src/redmine-net-api/Net/ApiResponseMessage.cs b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs
similarity index 77%
rename from src/redmine-net-api/Net/ApiResponseMessage.cs
rename to src/redmine-net-api/Http/Messages/RedmineApiResponse.cs
index 4cdf66c0..71fb1948 100644
--- a/src/redmine-net-api/Net/ApiResponseMessage.cs
+++ b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 89%
rename from src/redmine-net-api/Net/RedirectType.cs
rename to src/redmine-net-api/Http/RedirectType.cs
index 7793e23c..5bb7acf7 100644
--- a/src/redmine-net-api/Net/RedirectType.cs
+++ b/src/redmine-net-api/Http/RedirectType.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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..93b56aef
--- /dev/null
+++ b/src/redmine-net-api/Http/RedmineApiClient.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Net;
+using Redmine.Net.Api.Authentication;
+using Redmine.Net.Api.Extensions;
+using Redmine.Net.Api.Http.Constants;
+using Redmine.Net.Api.Http.Extensions;
+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;
+
+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 void LogRequest(string verb, string address, RequestOptions requestOptions)
+ {
+ if (Options.LoggingOptions?.IncludeHttpDetails != true)
+ {
+ return;
+ }
+
+ Options.Logger.Info($"Request HTTP {verb} {address}");
+
+ if (requestOptions?.QueryString != null)
+ {
+ Options.Logger.Info($"Query parameters: {requestOptions.QueryString.ToQueryString()}");
+ }
+ }
+
+ protected void LogResponse(int statusCode)
+ {
+ if (Options.LoggingOptions?.IncludeHttpDetails == true)
+ {
+ Options.Logger.Info($"Response status: {statusCode.ToInvariantString()}");
+ }
+ }
+}
\ 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 55%
rename from src/redmine-net-api/Net/WebClient/RedmineWebClientOptions.cs
rename to src/redmine-net-api/Http/RedmineApiClientOptions.cs
index 0a2953e8..7aa7f4e0 100644
--- a/src/redmine-net-api/Net/WebClient/RedmineWebClientOptions.cs
+++ b/src/redmine-net-api/Http/RedmineApiClientOptions.cs
@@ -1,31 +1,18 @@
-/*
- Copyright 2011 - 2023 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;
+#if NET || NET471_OR_GREATER
+using System.Net.Http;
+#endif
using System.Security.Cryptography.X509Certificates;
-namespace Redmine.Net.Api.Net.WebClient;
+namespace Redmine.Net.Api.Http;
+
///
///
///
-public sealed class RedmineWebClientOptions: IRedmineApiClientOptions
+public abstract class RedmineApiClientOptions : IRedmineApiClientOptions
{
///
///
@@ -40,7 +27,12 @@ public sealed class RedmineWebClientOptions: IRedmineApiClientOptions
///
///
///
- public DecompressionMethods? DecompressionFormat { get; set; }
+ public DecompressionMethods? DecompressionFormat { get; set; } =
+#if NET
+ DecompressionMethods.All;
+#else
+ DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.None;
+#endif
///
///
@@ -57,20 +49,12 @@ public sealed class RedmineWebClientOptions: IRedmineApiClientOptions
///
public IWebProxy Proxy { get; set; }
- ///
- ///
- ///
- public bool? KeepAlive { get; set; }
-
///
///
///
public int? MaxAutomaticRedirections { get; set; }
- ///
- ///
- ///
- public long? MaxRequestContentBufferSize { get; set; }
+
///
///
@@ -102,36 +86,38 @@ public sealed class RedmineWebClientOptions: IRedmineApiClientOptions
///
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 +129,15 @@ public sealed class RedmineWebClientOptions: IRedmineApiClientOptions
/// 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/Http/RequestOptions.cs b/src/redmine-net-api/Http/RequestOptions.cs
new file mode 100644
index 00000000..1e06ae53
--- /dev/null
+++ b/src/redmine-net-api/Http/RequestOptions.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.Collections.Generic;
+using System.Collections.Specialized;
+using Redmine.Net.Api.Extensions;
+
+namespace Redmine.Net.Api.Http;
+
+///
+///
+///
+public sealed class RequestOptions
+{
+ ///
+ ///
+ ///
+ public NameValueCollection QueryString { get; set; }
+ ///
+ ///
+ ///
+ public string ImpersonateUser { get; set; }
+ ///
+ ///
+ ///
+ public string ContentType { get; set; }
+ ///
+ ///
+ ///
+ public string Accept { get; set; }
+ ///
+ ///
+ ///
+ public string UserAgent { get; set; }
+
+ ///
+ ///
+ ///
+ public Dictionary Headers { get; set; }
+
+ ///
+ ///
+ ///
+ ///
+ public RequestOptions Clone()
+ {
+ return new RequestOptions
+ {
+ QueryString = QueryString != null ? new NameValueCollection(QueryString) : null,
+ ImpersonateUser = ImpersonateUser,
+ ContentType = ContentType,
+ Accept = Accept,
+ 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/ICloneableOfT.cs b/src/redmine-net-api/ICloneableOfT.cs
new file mode 100644
index 00000000..dd58fde2
--- /dev/null
+++ b/src/redmine-net-api/ICloneableOfT.cs
@@ -0,0 +1,14 @@
+namespace Redmine.Net.Api;
+
+///
+///
+///
+///
+public interface ICloneable
+{
+ ///
+ ///
+ ///
+ ///
+ internal T Clone(bool resetId);
+}
\ 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 94%
rename from src/redmine-net-api/IRedmineManagerAsync.cs
rename to src/redmine-net-api/IRedmineManager.Async.cs
index 7c77d334..0cb733b5 100644
--- a/src/redmine-net-api/IRedmineManagerAsync.cs
+++ b/src/redmine-net-api/IRedmineManager.Async.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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;
@@ -134,21 +137,23 @@ Task DeleteAsync(string id, RequestOptions requestOptions = null, Cancellatio
/// Upload a file to server. This method does not block the calling thread.
///
/// The content of the file that will be uploaded on server.
+ ///
///
///
///
/// .
///
- Task UploadFileAsync(byte[] data, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
+ Task UploadFileAsync(byte[] data, string fileName = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
///
/// Downloads the file asynchronous.
///
/// 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.cs b/src/redmine-net-api/IRedmineManager.cs
index b8148141..e4830a43 100644
--- a/src/redmine-net-api/IRedmineManager.cs
+++ b/src/redmine-net-api/IRedmineManager.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -14,17 +14,19 @@ 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;
///
///
///
-public partial interface IRedmineManager
+public interface IRedmineManager
{
///
///
@@ -93,23 +95,25 @@ void Update(string id, T entity, string projectId = null, RequestOptions requ
///
void Delete(string id, RequestOptions requestOptions = null)
where T : class, new();
-
+
///
/// 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);
-
+ 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/IRedmineManagerObsolete.cs b/src/redmine-net-api/IRedmineManagerObsolete.cs
deleted file mode 100644
index 553627f9..00000000
--- a/src/redmine-net-api/IRedmineManagerObsolete.cs
+++ /dev/null
@@ -1,306 +0,0 @@
-/*
- Copyright 2011 - 2023 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 (eg. 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/Internals/ArgumentNullThrowHelper.cs b/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs
new file mode 100644
index 00000000..e7daa84e
--- /dev/null
+++ b/src/redmine-net-api/Internals/ArgumentNullThrowHelper.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+
+#nullable enable
+
+namespace Redmine.Net.Api.Internals;
+
+internal static class ArgumentNullThrowHelper
+{
+ public static void ThrowIfNull(
+ #if INTERNAL_NULLABLE_ATTRIBUTES || NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
+ [NotNull]
+ #endif
+ object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
+ {
+ #if !NET7_0_OR_GREATER || NETSTANDARD || NETFRAMEWORK
+ if (argument is null)
+ {
+ Throw(paramName);
+ }
+ #else
+ ArgumentNullException.ThrowIfNull(argument, paramName);
+ #endif
+ }
+
+ #if !NET7_0_OR_GREATER || NETSTANDARD || NETFRAMEWORK
+ #if INTERNAL_NULLABLE_ATTRIBUTES || NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
+ [DoesNotReturn]
+ #endif
+ internal static void Throw(string? paramName) =>
+ throw new ArgumentNullException(paramName);
+ #endif
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Internals/HashCodeHelper.cs b/src/redmine-net-api/Internals/HashCodeHelper.cs
index ba8595c4..19f92d6a 100755
--- a/src/redmine-net-api/Internals/HashCodeHelper.cs
+++ b/src/redmine-net-api/Internals/HashCodeHelper.cs
@@ -1,5 +1,5 @@
ο»Ώ/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -43,11 +43,36 @@ public static int GetHashCode(IList list, int hash) where T : class
return hashCode;
}
- hashCode = (hashCode * 13) + list.Count;
+ hashCode = (hashCode * 17) + list.Count;
foreach (var t in list)
{
- hashCode *= 13;
+ hashCode *= 17;
+ if (t != null)
+ {
+ hashCode += t.GetHashCode();
+ }
+ }
+
+ return hashCode;
+ }
+ }
+
+ public static int GetHashCode(List list, int hash) where T : class
+ {
+ unchecked
+ {
+ var hashCode = hash;
+ if (list == null)
+ {
+ return hashCode;
+ }
+
+ hashCode = (hashCode * 17) + list.Count;
+
+ foreach (var t in list)
+ {
+ hashCode *= 17;
if (t != null)
{
hashCode += t.GetHashCode();
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/Internals/ParameterValidator.cs b/src/redmine-net-api/Internals/ParameterValidator.cs
new file mode 100644
index 00000000..55a4f944
--- /dev/null
+++ b/src/redmine-net-api/Internals/ParameterValidator.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace Redmine.Net.Api.Internals;
+
+///
+///
+///
+internal static class ParameterValidator
+{
+ public static void ValidateNotNull(T parameter, string parameterName)
+ where T : class
+ {
+ if (parameter is null)
+ {
+ throw new ArgumentNullException(parameterName);
+ }
+ }
+
+ public static void ValidateNotNullOrEmpty(string parameter, string parameterName)
+ {
+ if (string.IsNullOrEmpty(parameter))
+ {
+ throw new ArgumentException("Value cannot be null or empty", parameterName);
+ }
+ }
+
+ public static void ValidateId(int id, string parameterName)
+ {
+ if (id <= 0)
+ {
+ throw new ArgumentException("Id must be greater than 0", parameterName);
+ }
+ }
+}
\ 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/ApiRequestMessage.cs b/src/redmine-net-api/Net/ApiRequestMessage.cs
deleted file mode 100644
index c3bdb891..00000000
--- a/src/redmine-net-api/Net/ApiRequestMessage.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- Copyright 2011 - 2023 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;
-
-namespace Redmine.Net.Api.Net;
-
-internal sealed class ApiRequestMessage
-{
- public ApiRequestMessageContent Content { get; set; }
- public string Method { get; set; } = HttpVerbs.GET;
- public string RequestUri { get; set; }
- public NameValueCollection QueryString { get; set; }
- public string ImpersonateUser { get; set; }
-
- public string ContentType { get; set; }
-}
\ 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 e484c81a..00000000
--- a/src/redmine-net-api/Net/ApiRequestMessageContent.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- Copyright 2011 - 2023 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 36aeaf6e..00000000
--- a/src/redmine-net-api/Net/ApiResponseMessageExtensions.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- Copyright 2011 - 2023 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 bcd88271..00000000
--- a/src/redmine-net-api/Net/HttpVerbs.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-ο»Ώ/*
-Copyright 2011 - 2023 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 586a001a..00000000
--- a/src/redmine-net-api/Net/IRedmineApiClient.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- Copyright 2011 - 2023 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 263c703a..00000000
--- a/src/redmine-net-api/Net/IRedmineApiClientOptions.cs
+++ /dev/null
@@ -1,196 +0,0 @@
-/*
- Copyright 2011 - 2023 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 72%
rename from src/redmine-net-api/Net/RedmineApiUrls.cs
rename to src/redmine-net-api/Net/Internal/RedmineApiUrls.cs
index 34417919..8fa0b3a1 100644
--- a/src/redmine-net-api/Net/RedmineApiUrls.cs
+++ b/src/redmine-net-api/Net/Internal/RedmineApiUrls.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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},
@@ -112,6 +114,27 @@ public string CreateEntityFragment(string ownerId = null)
{
var type = typeof(T);
+ return CreateEntityFragment(type, ownerId);
+ }
+ public string CreateEntityFragment(RequestOptions requestOptions)
+ {
+ var type = typeof(T);
+
+ return CreateEntityFragment(type, requestOptions);
+ }
+ internal string CreateEntityFragment(Type type, RequestOptions requestOptions)
+ {
+ string ownerId = null;
+ if (requestOptions is { QueryString: not null })
+ {
+ ownerId = requestOptions.QueryString.Get(RedmineKeys.PROJECT_ID) ??
+ requestOptions.QueryString.Get(RedmineKeys.ISSUE_ID);
+ }
+
+ return CreateEntityFragment(type, ownerId);
+ }
+ internal string CreateEntityFragment(Type type, string ownerId = null)
+ {
if (type == typeof(Version) || type == typeof(IssueCategory) || type == typeof(ProjectMembership))
{
return ProjectParentFragment(ownerId, TypeUrlFragments[type]);
@@ -129,7 +152,7 @@ public string CreateEntityFragment(string ownerId = null)
if (type == typeof(Upload))
{
- return RedmineKeys.UPLOADS;
+ return UploadFragment(ownerId); //$"{RedmineKeys.UPLOADS}.{Format}";
}
if (type == typeof(Attachment) || type == typeof(Attachments))
@@ -144,6 +167,10 @@ public string CreateEntityFragment(string ownerId = null)
{
var type = typeof(T);
+ return GetFragment(type, id);
+ }
+ internal string GetFragment(Type type, string id)
+ {
return $"{TypeFragment(TypeUrlFragments, type)}/{id}.{Format}";
}
@@ -151,6 +178,10 @@ public string PatchFragment(string ownerId)
{
var type = typeof(T);
+ return PatchFragment(type, ownerId);
+ }
+ internal string PatchFragment(Type type, string ownerId)
+ {
if (type == typeof(Attachment) || type == typeof(Attachments))
{
return IssueAttachmentFragment(ownerId);
@@ -163,6 +194,10 @@ public string DeleteFragment(string id)
{
var type = typeof(T);
+ return DeleteFragment(type, id);
+ }
+ internal string DeleteFragment(Type type, string id)
+ {
return $"{TypeFragment(TypeUrlFragments, type)}/{id}.{Format}";
}
@@ -170,6 +205,10 @@ public string UpdateFragment(string id)
{
var type = typeof(T);
+ return UpdateFragment(type, id);
+ }
+ internal string UpdateFragment(Type type, string id)
+ {
return $"{TypeFragment(TypeUrlFragments, type)}/{id}.{Format}";
}
@@ -179,8 +218,27 @@ public string UpdateFragment(string id)
return GetListFragment(type, ownerId);
}
+
+ public string GetListFragment(RequestOptions requestOptions) where T : class, new()
+ {
+ var type = typeof(T);
+
+ return GetListFragment(type, requestOptions);
+ }
+
+ internal string GetListFragment(Type type, RequestOptions requestOptions)
+ {
+ string ownerId = null;
+ if (requestOptions is { QueryString: not null })
+ {
+ ownerId = requestOptions.QueryString.Get(RedmineKeys.PROJECT_ID) ??
+ requestOptions.QueryString.Get(RedmineKeys.ISSUE_ID);
+ }
+
+ return GetListFragment(type, ownerId);
+ }
- public string GetListFragment(Type type, string ownerId = null)
+ internal string GetListFragment(Type type, string ownerId = null)
{
if (type == typeof(Version) || type == typeof(IssueCategory) || type == typeof(ProjectMembership))
{
@@ -200,9 +258,11 @@ public string GetListFragment(Type type, string ownerId = null)
return $"{TypeFragment(TypeUrlFragments, type)}.{Format}";
}
- public string UploadFragment()
+ public string UploadFragment(string fileName = null)
{
- return $"{RedmineKeys.UPLOADS}.{Format}";
+ return !fileName.IsNullOrWhiteSpace()
+ ? $"{RedmineKeys.UPLOADS}.{Format}?filename={Uri.EscapeDataString(fileName)}"
+ : $"{RedmineKeys.UPLOADS}.{Format}";
}
}
}
\ No newline at end of file
diff --git a/src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs b/src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs
similarity index 67%
rename from src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs
rename to src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs
index b16ff47f..753b439d 100644
--- a/src/redmine-net-api/Net/RedmineApiUrlsExtensions.cs
+++ b/src/redmine-net-api/Net/Internal/RedmineApiUrlsExtensions.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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
{
@@ -31,24 +28,44 @@ public static string CurrentUser(this RedmineApiUrls redmineApiUrls)
return $"{RedmineKeys.USERS}/{RedmineKeys.CURRENT}.{redmineApiUrls.Format}";
}
+ public static string ProjectClose(this RedmineApiUrls redmineApiUrls, string projectIdentifier)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.CLOSE}.{redmineApiUrls.Format}";
+ }
+
+ public static string ProjectReopen(this RedmineApiUrls redmineApiUrls, string projectIdentifier)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.REOPEN}.{redmineApiUrls.Format}";
+ }
+
+ public static string ProjectArchive(this RedmineApiUrls redmineApiUrls, string projectIdentifier)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.ARCHIVE}.{redmineApiUrls.Format}";
+ }
+
+ public static string ProjectUnarchive(this RedmineApiUrls redmineApiUrls, string projectIdentifier)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.UNARCHIVE}.{redmineApiUrls.Format}";
+ }
+
+ public static string ProjectRepositoryAddRelatedIssue(this RedmineApiUrls redmineApiUrls, string projectIdentifier, string repositoryIdentifier, string revision)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.REPOSITORY}/{repositoryIdentifier}/{RedmineKeys.REVISIONS}/{revision}/{RedmineKeys.ISSUES}.{redmineApiUrls.Format}";
+ }
+
+ public static string ProjectRepositoryRemoveRelatedIssue(this RedmineApiUrls redmineApiUrls, string projectIdentifier, string repositoryIdentifier, string revision, string issueIdentifier)
+ {
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.REPOSITORY}/{repositoryIdentifier}/{RedmineKeys.REVISIONS}/{revision}/{RedmineKeys.ISSUES}/{issueIdentifier}.{redmineApiUrls.Format}";
+ }
+
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.PROJECT}/{projectIdentifier}/{RedmineKeys.MEMBERSHIPS}.{redmineApiUrls.Format}";
+ return $"{RedmineKeys.PROJECTS}/{projectIdentifier}/{RedmineKeys.MEMBERSHIPS}.{redmineApiUrls.Format}";
}
public static string ProjectWikiIndex(this RedmineApiUrls redmineApiUrls, string projectId)
@@ -88,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 7e3420b0..00000000
--- a/src/redmine-net-api/Net/WebClient/Extensions/NameValueCollectionExtensions.cs
+++ /dev/null
@@ -1,140 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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 9d454562..00000000
--- a/src/redmine-net-api/Net/WebClient/Extensions/WebExtensions.cs
+++ /dev/null
@@ -1,146 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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 1f6be22c..00000000
--- a/src/redmine-net-api/Net/WebClient/IRedmineWebClientObsolete.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- Copyright 2011 - 2023 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/InternalRedmineApiWebClient.cs b/src/redmine-net-api/Net/WebClient/InternalRedmineApiWebClient.cs
deleted file mode 100644
index 0ee22f25..00000000
--- a/src/redmine-net-api/Net/WebClient/InternalRedmineApiWebClient.cs
+++ /dev/null
@@ -1,353 +0,0 @@
-/*
- Copyright 2011 - 2023 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 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.ClientOptions);
- }
-
- public InternalRedmineApiWebClient(Func webClientFunc, IRedmineAuthentication authentication, IRedmineSerializer serializer)
- {
- _webClientFunc = webClientFunc;
- _credentials = authentication;
- _serializer = serializer;
- }
-
- private static void ConfigureServicePointManager(IRedmineApiClientOptions webClientSettings)
- {
- if (webClientSettings.MaxServicePoints.HasValue)
- {
- ServicePointManager.MaxServicePoints = webClientSettings.MaxServicePoints.Value;
- }
-
- if (webClientSettings.MaxServicePointIdleTime.HasValue)
- {
- ServicePointManager.MaxServicePointIdleTime = webClientSettings.MaxServicePointIdleTime.Value;
- }
-
- ServicePointManager.SecurityProtocol = webClientSettings.SecurityProtocolType ?? ServicePointManager.SecurityProtocol;
-
- if (webClientSettings.DefaultConnectionLimit.HasValue)
- {
- ServicePointManager.DefaultConnectionLimit = webClientSettings.DefaultConnectionLimit.Value;
- }
-
- if (webClientSettings.DnsRefreshTimeout.HasValue)
- {
- ServicePointManager.DnsRefreshTimeout = webClientSettings.DnsRefreshTimeout.Value;
- }
-
- ServicePointManager.CheckCertificateRevocationList = webClientSettings.CheckCertificateRevocationList;
-
- if (webClientSettings.EnableDnsRoundRobin.HasValue)
- {
- ServicePointManager.EnableDnsRoundRobin = webClientSettings.EnableDnsRoundRobin.Value;
- }
-
- #if(NET46_OR_GREATER || NETCOREAPP)
- if (webClientSettings.ReusePort.HasValue)
- {
- ServicePointManager.ReusePort = webClientSettings.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 (requestMessage.Method is HttpVerbs.GET or HttpVerbs.DOWNLOAD)
- {
- 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 = Encoding.UTF8.GetBytes(string.Empty);
- }
-
- 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 = Encoding.UTF8.GetBytes(string.Empty);
- }
-
- 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/InternalWebClient.cs b/src/redmine-net-api/Net/WebClient/InternalWebClient.cs
deleted file mode 100644
index 27051719..00000000
--- a/src/redmine-net-api/Net/WebClient/InternalWebClient.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- Copyright 2011 - 2023 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.Exceptions;
-using Redmine.Net.Api.Extensions;
-
-namespace Redmine.Net.Api.Net.WebClient;
-
-internal sealed class InternalWebClient : System.Net.WebClient
-{
- private readonly IRedmineApiClientOptions _webClientSettings;
-
- #pragma warning disable SYSLIB0014
- public InternalWebClient(RedmineManagerOptions redmineManagerOptions)
- {
- _webClientSettings = redmineManagerOptions.ClientOptions;
- BaseAddress = redmineManagerOptions.BaseAddress.ToString();
- }
- #pragma warning restore SYSLIB0014
-
- protected override WebRequest GetWebRequest(Uri address)
- {
- try
- {
- var webRequest = base.GetWebRequest(address);
-
- if (webRequest is not HttpWebRequest httpWebRequest)
- {
- return base.GetWebRequest(address);
- }
-
- httpWebRequest.UserAgent = _webClientSettings.UserAgent.ValueOrFallback("RedmineDotNetAPIClient");
-
- httpWebRequest.AutomaticDecompression = _webClientSettings.DecompressionFormat ?? DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.None;
-
- AssignIfHasValue(_webClientSettings.AutoRedirect, value => httpWebRequest.AllowAutoRedirect = value);
-
- AssignIfHasValue(_webClientSettings.MaxAutomaticRedirections, value => httpWebRequest.MaximumAutomaticRedirections = value);
-
- AssignIfHasValue(_webClientSettings.KeepAlive, value => httpWebRequest.KeepAlive = value);
-
- AssignIfHasValue(_webClientSettings.Timeout, value => httpWebRequest.Timeout = (int) value.TotalMilliseconds);
-
- AssignIfHasValue(_webClientSettings.PreAuthenticate, value => httpWebRequest.PreAuthenticate = value);
-
- AssignIfHasValue(_webClientSettings.UseCookies, value => httpWebRequest.CookieContainer = _webClientSettings.CookieContainer);
-
- AssignIfHasValue(_webClientSettings.UnsafeAuthenticatedConnectionSharing, value => httpWebRequest.UnsafeAuthenticatedConnectionSharing = value);
-
- AssignIfHasValue(_webClientSettings.MaxResponseContentBufferSize, value => { });
-
- if (_webClientSettings.DefaultHeaders != null)
- {
- httpWebRequest.Headers = new WebHeaderCollection();
- foreach (var defaultHeader in _webClientSettings.DefaultHeaders)
- {
- httpWebRequest.Headers.Add(defaultHeader.Key, defaultHeader.Value);
- }
- }
-
- httpWebRequest.CachePolicy = _webClientSettings.RequestCachePolicy;
-
- httpWebRequest.Proxy = _webClientSettings.Proxy;
-
- httpWebRequest.Credentials = _webClientSettings.Credentials;
-
- #if !(NET20)
- if (_webClientSettings.ClientCertificates != null)
- {
- httpWebRequest.ClientCertificates = _webClientSettings.ClientCertificates;
- }
- #endif
-
- #if (NET45_OR_GREATER || NETCOREAPP)
- httpWebRequest.ServerCertificateValidationCallback = _webClientSettings.ServerCertificateValidationCallback;
- #endif
-
- if (_webClientSettings.ProtocolVersion != default)
- {
- httpWebRequest.ProtocolVersion = _webClientSettings.ProtocolVersion;
- }
-
- return httpWebRequest;
- }
- catch (Exception webException)
- {
- throw new RedmineException(webException.GetBaseException().Message, webException);
- }
- }
-
- private static void AssignIfHasValue(T? nullableValue, Action assignAction) where T : struct
- {
- if (nullableValue.HasValue)
- {
- assignAction(nullableValue.Value);
- }
- }
-}
\ No newline at end of file
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 4f72fc83..00000000
--- a/src/redmine-net-api/Net/WebClient/MessageContent/ByteArrayApiRequestMessageContent.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- Copyright 2011 - 2023 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 e7527234..00000000
--- a/src/redmine-net-api/Net/WebClient/MessageContent/StreamApiRequestMessageContent.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- Copyright 2011 - 2023 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 9d02d69a..00000000
--- a/src/redmine-net-api/Net/WebClient/MessageContent/StringApiRequestMessageContent.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- Copyright 2011 - 2023 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;
-
-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)
- {
- #if NET5_0_OR_GREATER
- ArgumentNullException.ThrowIfNull(content);
- #else
- if (content == null)
- {
- throw new ArgumentNullException(nameof(content));
- }
- #endif
- return (encoding ?? DefaultStringEncoding).GetBytes(content);
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Net/WebClient/RedmineWebClientObsolete.cs b/src/redmine-net-api/Net/WebClient/RedmineWebClientObsolete.cs
deleted file mode 100644
index 688a499e..00000000
--- a/src/redmine-net-api/Net/WebClient/RedmineWebClientObsolete.cs
+++ /dev/null
@@ -1,260 +0,0 @@
-/*
- Copyright 2011 - 2023 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 64%
rename from src/redmine-net-api/RedmineManagerOptions.cs
rename to src/redmine-net-api/Options/RedmineManagerOptions.cs
index aadf9916..7093c073 100644
--- a/src/redmine-net-api/RedmineManagerOptions.cs
+++ b/src/redmine-net-api/Options/RedmineManagerOptions.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -17,10 +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.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
{
///
///
@@ -52,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.
///
@@ -60,13 +83,24 @@ internal sealed class RedmineManagerOptions
///
/// Gets or sets the settings for configuring the Redmine web client.
///
- public IRedmineApiClientOptions ClientOptions { 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 f8fdad88..fa752ce4 100644
--- a/src/redmine-net-api/RedmineConstants.cs
+++ b/src/redmine-net-api/RedmineConstants.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -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 5bd7661e..b5072aa6 100644
--- a/src/redmine-net-api/RedmineKeys.cs
+++ b/src/redmine-net-api/RedmineKeys.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -59,6 +59,10 @@ public static class RedmineKeys
///
///
///
+ public const string ARCHIVE = "archive";
+ ///
+ ///
+ ///
public const string ASSIGNED_TO = "assigned_to";
///
///
@@ -67,7 +71,7 @@ public static class RedmineKeys
///
///
///
- public const string ASSIGNABLE = "Assignable";
+ public const string ASSIGNABLE = "assignable";
///
///
///
@@ -84,6 +88,11 @@ public static class RedmineKeys
///
///
public const string AUTH_SOURCE_ID = "auth_source_id";
+
+ ///
+ ///
+ ///
+ public const string AVATAR_URL = "avatar_url";
///
///
///
@@ -107,6 +116,10 @@ public static class RedmineKeys
///
///
///
+ public const string CLOSE = "close";
+ ///
+ ///
+ ///
public const string CLOSED_ON = "closed_on";
///
///
@@ -166,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";
+
///
///
///
@@ -224,6 +242,10 @@ public static class RedmineKeys
///
///
///
+ public const string DOCUMENT_CATEGORY = "document_category";
+ ///
+ ///
+ ///
public const string DOCUMENTS = "documents";
///
///
@@ -248,6 +270,14 @@ public static class RedmineKeys
///
///
///
+ public const string ENABLED_STANDARD_FIELDS = "enabled_standard_fields";
+ ///
+ ///
+ ///
+ public const string ENUMERATION_DOCUMENT_CATEGORIES = "enumerations/document_categories";
+ ///
+ ///
+ ///
public const string ENUMERATION_ISSUE_PRIORITIES = "enumerations/issue_priorities";
///
///
@@ -268,6 +298,10 @@ public static class RedmineKeys
///
///
///
+ public const string FIELD = "field";
+ ///
+ ///
+ ///
public const string FIELD_FORMAT = "field_format";
///
///
@@ -301,6 +335,10 @@ public static class RedmineKeys
///
///
///
+ public const string GENERATE_PASSWORD = "generate_password";
+ ///
+ ///
+ ///
public const string GROUP = "group";
///
///
@@ -358,7 +396,10 @@ public static class RedmineKeys
///
///
public const string ISSUE_CATEGORY = "issue_category";
-
+ ///
+ ///
+ ///
+ public const string ISSUE_CUSTOM_FIELDS = "issue_custom_fields";
///
///
///
@@ -615,6 +656,14 @@ public static class RedmineKeys
///
///
///
+ public const string REOPEN = "reopen";
+ ///
+ ///
+ ///
+ public const string REPOSITORY = "repository";
+ ///
+ ///
+ ///
public const string RESULT = "result";
///
///
@@ -623,6 +672,10 @@ public static class RedmineKeys
///
///
///
+ public const string REVISIONS = "revisions";
+ ///
+ ///
+ ///
public const string ROLE = "role";
///
///
@@ -647,6 +700,10 @@ public static class RedmineKeys
///
///
///
+ public const string SEND_INFORMATION = "send_information";
+ ///
+ ///
+ ///
public const string SEARCH = "search";
///
///
@@ -768,10 +825,18 @@ public static class RedmineKeys
///
///
///
+ public const string UNARCHIVE = "unarchive";
+ ///
+ ///
+ ///
public const string UPDATED_ON = "updated_on";
///
///
///
+ public const string UPDATED_BY = "updated_by";
+ ///
+ ///
+ ///
public const string UPLOAD = "upload";
///
///
@@ -849,7 +914,5 @@ public static class RedmineKeys
///
///
public const string WIKI_PAGES = "wiki_pages";
-
-
}
}
\ No newline at end of file
diff --git a/src/redmine-net-api/RedmineManagerAsync.cs b/src/redmine-net-api/RedmineManager.Async.cs
similarity index 71%
rename from src/redmine-net-api/RedmineManagerAsync.cs
rename to src/redmine-net-api/RedmineManager.Async.cs
index 20485248..bf48328e 100644
--- a/src/redmine-net-api/RedmineManagerAsync.cs
+++ b/src/redmine-net-api/RedmineManager.Async.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2023 Adrian Popescu
+ 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.
@@ -18,21 +18,24 @@ limitations under the License.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
-using System.Text.RegularExpressions;
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;
public partial class RedmineManager: IRedmineManagerAsync
{
- private const string CRLR = "\r\n";
-
///
public async Task CountAsync(RequestOptions requestOptions, CancellationToken cancellationToken = default)
where T : class, new()
@@ -56,7 +59,7 @@ public async Task CountAsync(RequestOptions requestOptions, Cancellation
public async Task> GetPagedAsync(RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
where T : class, new()
{
- var url = RedmineApiUrls.GetListFragment();
+ var url = RedmineApiUrls.GetListFragment(requestOptions);
var response = await ApiClient.GetAsync(url, requestOptions, cancellationToken).ConfigureAwait(false);
@@ -72,67 +75,57 @@ public async Task> GetAsync(RequestOptions requestOptions = null, Can
var isLimitSet = false;
List resultList = null;
- requestOptions ??= new RequestOptions();
-
- if (requestOptions.QueryString == null)
+ var baseRequestOptions = requestOptions != null ? requestOptions.Clone() : new RequestOptions();
+ if (baseRequestOptions.QueryString == null)
{
- requestOptions.QueryString = new NameValueCollection();
+ baseRequestOptions.QueryString = new NameValueCollection();
}
else
{
- isLimitSet = int.TryParse(requestOptions.QueryString[RedmineKeys.LIMIT], out pageSize);
- int.TryParse(requestOptions.QueryString[RedmineKeys.OFFSET], out offset);
+ isLimitSet = int.TryParse(baseRequestOptions.QueryString[RedmineKeys.LIMIT], out pageSize);
+ int.TryParse(baseRequestOptions.QueryString[RedmineKeys.OFFSET], out offset);
}
if (pageSize == default)
{
- pageSize = PageSize > 0
- ? PageSize
+ pageSize = _redmineManagerOptions.PageSize > 0
+ ? _redmineManagerOptions.PageSize
: RedmineConstants.DEFAULT_PAGE_SIZE_VALUE;
- requestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToInvariantString());
+ baseRequestOptions.QueryString.Set(RedmineKeys.LIMIT, pageSize.ToInvariantString());
}
-
- var hasOffset = RedmineManager.TypesWithOffset.ContainsKey(typeof(T));
+
+ var hasOffset = TypesWithOffset.ContainsKey(typeof(T));
if (hasOffset)
{
- requestOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToInvariantString());
-
- var tempResult = await GetPagedAsync(requestOptions, cancellationToken).ConfigureAwait(false);
+ var firstPageOptions = baseRequestOptions.Clone();
+ firstPageOptions.QueryString.Set(RedmineKeys.OFFSET, offset.ToInvariantString());
+ var firstPage = await GetPagedAsync(firstPageOptions, cancellationToken).ConfigureAwait(false);
- var totalCount = isLimitSet ? pageSize : tempResult.TotalItems;
-
- if (tempResult?.Items != null)
+ if (firstPage == null || firstPage.Items == null)
{
- resultList = new List(tempResult.Items);
+ return null;
}
-
- var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
-
- var remainingPages = totalPages - offset / pageSize;
+
+ var totalCount = isLimitSet ? pageSize : firstPage.TotalItems;
+ resultList = new List(firstPage.Items);
+ var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
+ var remainingPages = totalPages - 1 - (offset / pageSize);
if (remainingPages <= 0)
{
return resultList;
}
-
+
using (var semaphore = new SemaphoreSlim(MAX_CONCURRENT_TASKS))
{
var pageFetchTasks = new List>>();
-
- for (int page = 0; page < remainingPages; page++)
+ for (int page = 1; page <= remainingPages; page++)
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
-
- var innerOffset = (page * pageSize) + offset;
-
- pageFetchTasks.Add(GetPagedInternalAsync(semaphore, new RequestOptions()
- {
- QueryString = new NameValueCollection()
- {
- {RedmineKeys.OFFSET, innerOffset.ToInvariantString()},
- {RedmineKeys.LIMIT, pageSize.ToInvariantString()}
- }
- }, cancellationToken));
+ var pageOffset = (page * pageSize) + offset;
+ var pageRequestOptions = baseRequestOptions.Clone();
+ pageRequestOptions.QueryString.Set(RedmineKeys.OFFSET, pageOffset.ToInvariantString());
+ pageFetchTasks.Add(GetPagedInternalAsync(semaphore, pageRequestOptions, cancellationToken));
}
var pageResults = await
@@ -142,30 +135,27 @@ public async Task> GetAsync(RequestOptions requestOptions = null, Can
TaskExtensions.WhenAll(pageFetchTasks)
#endif
.ConfigureAwait(false);
-
+
foreach (var pageResult in pageResults)
{
if (pageResult?.Items == null)
{
continue;
}
-
- resultList ??= new List();
-
resultList.AddRange(pageResult.Items);
}
}
}
else
{
- var result = await GetPagedAsync(requestOptions, cancellationToken: cancellationToken)
+ var result = await GetPagedAsync(baseRequestOptions, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result?.Items != null)
{
return new List(result.Items);
}
}
-
+
return resultList;
}
@@ -208,11 +198,7 @@ public async Task UpdateAsync(string id, T entity, RequestOptions requestOpti
var payload = Serializer.Serialize(entity);
- #if NET7_0_OR_GREATER
- payload = ReplaceEndingsRegex().Replace(payload, CRLR);
- #else
- payload = Regex.Replace(payload, "\r\n|\r|\n",CRLR);
- #endif
+ payload = payload.ReplaceEndings();
await ApiClient.UpdateAsync(url, payload, requestOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
@@ -227,26 +213,21 @@ public async Task DeleteAsync(string id, RequestOptions requestOptions = null
}
///
- public async Task UploadFileAsync(byte[] data, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ public async Task UploadFileAsync(byte[] data, string fileName = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
{
- var url = RedmineApiUrls.UploadFragment();
+ 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;
}
-
- #if NET7_0_OR_GREATER
- [GeneratedRegex(@"\r\n|\r|\n")]
- private static partial Regex ReplaceEndingsRegex();
- #endif
private const int MAX_CONCURRENT_TASKS = 3;
@@ -255,7 +236,7 @@ private async Task> GetPagedInternalAsync(SemaphoreSlim semap
{
try
{
- var url = RedmineApiUrls.GetListFragment();
+ var url = RedmineApiUrls.GetListFragment(requestOptions);
var response = await ApiClient.GetAsync(url, requestOptions, cancellationToken).ConfigureAwait(false);
diff --git a/src/redmine-net-api/RedmineManager.cs b/src/redmine-net-api/RedmineManager.cs
index f683dc3a..ca062ef8 100644
--- a/src/redmine-net-api/RedmineManager.cs
+++ b/src/redmine-net-api/RedmineManager.cs
@@ -1,5 +1,5 @@
-ο»Ώ/*
- Copyright 2011 - 2023 Adrian Popescu
+/*
+ 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.
@@ -17,12 +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.Net;
-using Redmine.Net.Api.Net.WebClient;
+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.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;
@@ -38,6 +45,7 @@ public partial class RedmineManager : IRedmineManager
internal IRedmineSerializer Serializer { get; }
internal RedmineApiUrls RedmineApiUrls { get; }
internal IRedmineApiClient ApiClient { get; }
+ internal IRedmineLogger Logger { get; }
///
///
@@ -46,43 +54,100 @@ public partial class RedmineManager : IRedmineManager
///
public RedmineManager(RedmineManagerOptionsBuilder optionsBuilder)
{
- #if NET5_0_OR_GREATER
- ArgumentNullException.ThrowIfNull(optionsBuilder);
- #else
- if (optionsBuilder == null)
- {
- throw new ArgumentNullException(nameof(optionsBuilder));
- }
- #endif
+ ArgumentNullThrowHelper.ThrowIfNull(optionsBuilder, nameof(optionsBuilder));
+
_redmineManagerOptions = optionsBuilder.Build();
- if (_redmineManagerOptions.VerifyServerCert)
+
+ Logger = _redmineManagerOptions.Logger;
+ Serializer = _redmineManagerOptions.Serializer;
+ RedmineApiUrls = new RedmineApiUrls(_redmineManagerOptions.Serializer.Format);
+
+ ApiClient =
+#if NET40_OR_GREATER || NET
+ _redmineManagerOptions.ApiClientOptions switch
{
- _redmineManagerOptions.ClientOptions.ServerCertificateValidationCallback = RemoteCertValidate;
- }
+ RedmineWebClientOptions => CreateWebClient(_redmineManagerOptions),
+ RedmineHttpClientOptions => CreateHttpClient(_redmineManagerOptions),
+ };
+#else
+ CreateWebClient(_redmineManagerOptions);
+#endif
+ }
- Serializer = _redmineManagerOptions.Serializer;
-
- Host = _redmineManagerOptions.BaseAddress.ToString();
- PageSize = _redmineManagerOptions.PageSize;
- Format = Serializer.Format;
- Scheme = _redmineManagerOptions.BaseAddress.Scheme;
- Proxy = _redmineManagerOptions.ClientOptions.Proxy;
- Timeout = _redmineManagerOptions.ClientOptions.Timeout;
- MimeFormat = RedmineConstants.XML.Equals(Serializer.Format, StringComparison.OrdinalIgnoreCase) ? MimeFormat.Xml : MimeFormat.Json;
-
- _redmineManagerOptions.ClientOptions.SecurityProtocolType ??= ServicePointManager.SecurityProtocol;
-
- SecurityProtocolType = _redmineManagerOptions.ClientOptions.SecurityProtocolType.Value;
-
- if (_redmineManagerOptions.Authentication is RedmineApiKeyAuthentication)
+ private static InternalRedmineApiWebClient CreateWebClient(RedmineManagerOptions options)
+ {
+ if (options.ClientFunc != null)
{
- ApiKey = _redmineManagerOptions.Authentication.Token;
+ return new InternalRedmineApiWebClient(options.ClientFunc, options);
}
-
- RedmineApiUrls = new RedmineApiUrls(Serializer.Format);
- ApiClient = new InternalRedmineApiWebClient(_redmineManagerOptions);
+
+ 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)
+ {
+ return;
+ }
+
+ if (options.SecurityProtocolType.HasValue)
+ {
+ ServicePointManager.SecurityProtocol = options.SecurityProtocolType.Value;
+ }
+
+ if (options.DefaultConnectionLimit.HasValue)
+ {
+ ServicePointManager.DefaultConnectionLimit = options.DefaultConnectionLimit.Value;
+ }
+
+ if (options.DnsRefreshTimeout.HasValue)
+ {
+ ServicePointManager.DnsRefreshTimeout = options.DnsRefreshTimeout.Value;
+ }
+
+ 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()
@@ -91,14 +156,11 @@ 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);
- var tempResult = GetPaginatedObjects(requestOptions.QueryString);
+ var tempResult = GetPaginated(requestOptions);
if (tempResult != null)
{
@@ -123,18 +185,18 @@ public T Get(string id, RequestOptions requestOptions = null)
public List Get(RequestOptions requestOptions = null)
where T : class, new()
{
- var uri = RedmineApiUrls.GetListFragment();
+ var uri = RedmineApiUrls.GetListFragment(requestOptions);
- return GetObjects(uri, requestOptions);
+ return GetInternal(uri, requestOptions);
}
///
public PagedResults GetPaginated(RequestOptions requestOptions = null)
where T : class, new()
{
- var url = RedmineApiUrls.GetListFragment();
+ var url = RedmineApiUrls.GetListFragment(requestOptions);
- return GetPaginatedObjects(url, requestOptions);
+ return GetPaginatedInternal(url, requestOptions);
}
///
@@ -158,6 +220,8 @@ public void Update(string id, T entity, string projectId = null, RequestOptio
var payload = Serializer.Serialize(entity);
+ payload = payload.ReplaceEndings();
+
ApiClient.Update(url, payload, requestOptions);
}
@@ -171,9 +235,9 @@ public void Delete(string id, RequestOptions requestOptions = null)
}
///
- public Upload UploadFile(byte[] data)
+ public Upload UploadFile(byte[] data, string fileName = null)
{
- var url = RedmineApiUrls.UploadFragment();
+ var url = RedmineApiUrls.UploadFragment(fileName);
var response = ApiClient.Upload(url, data);
@@ -181,9 +245,9 @@ public Upload UploadFile(byte[] data)
}
///
- 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;
}
@@ -195,7 +259,7 @@ public byte[] DownloadFile(string address)
///
///
///
- internal List GetObjects(string uri, RequestOptions requestOptions = null)
+ internal List GetInternal(string uri, RequestOptions requestOptions = null)
where T : class, new()
{
int pageSize = 0, offset = 0;
@@ -217,7 +281,7 @@ internal List GetObjects(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));
@@ -226,9 +290,9 @@ internal List GetObjects(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 = GetPaginatedObjects(uri, requestOptions);
+ var tempResult = GetPaginatedInternal(uri, requestOptions);
totalCount = isLimitSet ? pageSize : tempResult.TotalItems;
@@ -250,7 +314,7 @@ internal List GetObjects(string uri, RequestOptions requestOptions = null)
}
else
{
- var result = GetPaginatedObjects(uri, requestOptions);
+ var result = GetPaginatedInternal(uri, requestOptions);
if (result?.Items != null)
{
return new List(result.Items);
@@ -267,14 +331,25 @@ internal List GetObjects(string uri, RequestOptions requestOptions = null)
///
///
///
- internal PagedResults GetPaginatedObjects(string uri = null, RequestOptions requestOptions = null)
+ internal PagedResults GetPaginatedInternal(string uri = null, RequestOptions requestOptions = null)
where T : class, new()
{
- uri = uri.IsNullOrWhiteSpace() ? RedmineApiUrls.GetListFragment() : uri;
+ uri = uri.IsNullOrWhiteSpace() ? RedmineApiUrls.GetListFragment(requestOptions) : uri;
var response= ApiClient.Get(uri, requestOptions);
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/RedmineManagerObsolete.cs b/src/redmine-net-api/RedmineManagerObsolete.cs
deleted file mode 100644
index c3bdfec6..00000000
--- a/src/redmine-net-api/RedmineManagerObsolete.cs
+++ /dev/null
@@ -1,636 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2023 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)
- .WithClientOptions(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 -> Settings -> 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)
- .WithClientOptions(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)
- .WithClientOptions(new RedmineWebClientOptions()
- {
- Proxy = proxy,
- Scheme = scheme,
- Timeout = timeout,
- SecurityProtocolType = securityProtocolType
- })) {}
-
-
- ///
- /// Gets the suffixes.
- ///
- ///
- /// The suffixes.
- ///
- [Obsolete(RedmineConstants.OBSOLETE_TEXT)]
- 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)]
- public T GetObject(string id, NameValueCollection parameters) where T : class, new()
- {
- var url = RedmineApiUrls.GetFragment(id);
-
- var response = ApiClient.Get(url, parameters != null ? new RequestOptions { QueryString = parameters } : null);
-
- return response.DeserializeTo(Serializer);
- }
-
- ///
- /// 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()
- {
- var uri = RedmineApiUrls.GetListFragment();
-
- return GetObjects(uri, 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()
- {
- var url = RedmineApiUrls.GetListFragment();
-
- return GetPaginatedObjects(url, 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 CreateObject(entity, null);
- }
-
- ///
- /// 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()
- {
- var url = RedmineApiUrls.CreateEntityFragment(ownerId);
-
- var payload = Serializer.Serialize(entity);
-
- var response = ApiClient.Create(url, payload);
-
- return response.DeserializeTo(Serializer);
- }
-
- ///
- /// Updates a Redmine object.
- ///
- /// The type of object to be update.
- /// The id of the object to be update.
- /// The object to be update.
- /// 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()
- {
- var url = RedmineApiUrls.UpdateFragment(id);
-
- var payload = Serializer.Serialize(entity);
-
- ApiClient.Update(url, payload);
- }
-
- ///
- /// 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()
- {
- var url = RedmineApiUrls.DeleteFragment(id);
-
- ApiClient.Delete(url, 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 WebClientSettings.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/RedmineManagerOptionsBuilder.cs b/src/redmine-net-api/RedmineManagerOptionsBuilder.cs
deleted file mode 100644
index 8e682f0b..00000000
--- a/src/redmine-net-api/RedmineManagerOptionsBuilder.cs
+++ /dev/null
@@ -1,382 +0,0 @@
-/*
- Copyright 2011 - 2023 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
- {
- None,
- WebClient,
- }
- private ClientType _clientType = ClientType.None;
-
- ///
- ///
- ///
- ///
- ///
- 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;
- }
-
- ///
- ///
- ///
- 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)
- {
- if (clientFunc != null)
- {
- _clientType = ClientType.WebClient;
- }
-
- if (clientFunc == null && _clientType == ClientType.WebClient)
- {
- _clientType = ClientType.None;
- }
- this.ClientFunc = clientFunc;
- return this;
- }
-
- ///
- ///
- ///
- public Func ClientFunc { get; private set; }
-
- ///