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
new file mode 100644
index 00000000..bdc96713
--- /dev/null
+++ b/.github/workflows/build-and-test.yml
@@ -0,0 +1,123 @@
+name: 'Build and Test'
+
+on:
+ workflow_call:
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'The reason for running the workflow'
+ required: false
+ default: 'Manual build and run tests'
+ push:
+ tags-ignore:
+ - '[0-9]+.[0-9]+.[0-9]+*'
+ paths:
+ - '**.cs'
+ - '**.csproj'
+ - '**.sln'
+ pull_request:
+ branches: [ master ]
+ paths:
+ - '**.cs'
+ - '**.csproj'
+ - '**.sln'
+
+# concurrency:
+# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+# cancel-in-progress: true
+
+env:
+ # Disable the .NET logo in the console output.
+ DOTNET_NOLOGO: true
+
+ # Stop wasting time caching packages
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+
+ # Disable sending usage data to Microsoft
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+
+ DOTNET_ADD_GLOBAL_TOOLS_TO_PATH: false
+
+ DOTNET_MULTILEVEL_LOOKUP: 0
+
+ PROJECT_PATH: .
+
+ CONFIGURATION: Release
+
+jobs:
+ before:
+ name: Before
+ runs-on: ubuntu-latest
+ steps:
+ - name: Info Before
+ run: |
+ echo "[${{ github.event_name }}] event automatically triggered this job."
+ echo "branch name is ${{ github.ref }}"
+ echo "This job has a '${{ job.status }}' status."
+ - name: Run a one-line script
+ run: |
+ echo "Is true: $( [ \"$EVENT_NAME\" = 'push' ] && [ \"$GITHUB_REF\" != 'refs/tags/' ] ) || [ \"$EVENT_NAME\" = 'workflow_dispatch' ]"
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF: ${{ github.ref }}
+
+ build:
+ needs: before
+ name: Build ${{ matrix.os }} - dotnet ${{ matrix.dotnet }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ ubuntu-latest, windows-latest, macos-latest ]
+
+ steps:
+ - name: Print manual run reason
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ run: |
+ echo 'Reason: ${{ github.event.inputs.reason }}'
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: Setup .NET (global.json)
+ uses: actions/setup-dotnet@v4
+
+ - name: Display dotnet version
+ run: dotnet --version
+
+ - uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ # Look to see if there is a cache hit for the corresponding requirements file
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget
+
+ - name: Restore
+ run: dotnet restore "${{ env.PROJECT_PATH }}"
+
+ - name: π¨ Build
+ run: >-
+ dotnet build "${{ env.PROJECT_PATH }}"
+ --configuration "${{ env.CONFIGURATION }}"
+ --no-restore
+
+ - name: Test
+ timeout-minutes: 60
+ run: >-
+ dotnet test "${{ env.PROJECT_PATH }}"
+ --no-restore
+ --no-build
+ --verbosity normal
+ --logger trx
+ --results-directory "TestResults-${{ matrix.os }}" || true
+
+ - name: Upload test results
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-${{ matrix.os }}
+ path: TestResults-${{ matrix.os }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 00000000..4c4bd893
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,70 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ master ]
+ schedule:
+ - cron: '34 7 * * 6'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ # Learn more about CodeQL language support at https://git.io/codeql-language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # βΉοΈ Command-line programs to run using the OS shell.
+ # π https://git.io/JvXDl
+
+ # βοΈ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml
deleted file mode 100644
index d17aef8e..00000000
--- a/.github/workflows/dotnetcore.yml
+++ /dev/null
@@ -1,128 +0,0 @@
-name: Redmine .NET Api
-
-on:
- push:
- paths-ignore:
- - '**/*.md'
- - '**/*.gif'
- - '**/*.png'
- - '**/*.gitignore'
- - '**/*.gitattributes'
- - LICENSE
- - tests/*
- tags:
- - v[1-9].[0-9]+.[0-9]+
- pull_request:
- workflow_dispatch:
- branches:
- - main
- path-ignore:
- - '**/*.md'
- - '**/*.gif'
- - '**/*.png'
- - '**/*.gitignore'
- - '**/*.gitattributes'
- - LICENSE
- - tests/*
-
-env:
- DOTNET_CLI_TELEMETRY_OPTOUT: 1
- DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
- DOTNET_NOLOGO: true
- DOTNET_GENERATE_ASPNET_CERTIFICATE: false
- DOTNET_ADD_GLOBAL_TOOLS_TO_PATH: false
- DOTNET_MULTILEVEL_LOOKUP: 0
-
-jobs:
- build:
-
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest, macOS-latest]
- dotnet: [ '3.1.x']
- name: OS ${{ matrix.os }} - dotnet ${{ matrix.dotnet }}
-
- steps:
- - uses: actions/checkout@v2
-
- - name: Setup .NET Core SDK
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: ${{ matrix.dotnet }}
- # Fetches all tags for the repo
- - name: Fetch tags
- run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
-
- - name: Install dependencies
- run: dotnet restore redmine-net-api.sln
-
- - name: Get the version
- #id: get_version
- #run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
- #${{ steps.get_version.outputs.VERSION }}
- run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
-
- - name: Test
- run: |
- echo $VERSION
- echo ${{ env.VERSION }}
- echo $github.run_number
-
-# - name: Build
-# run: dotnet build redmine-net-api.sln --configuration Release --no-restore --version-suffix=${{ env.VERSION }}
-
-# - name: Build Signed
-# run: dotnet build redmine-net-api.sln --configuration Release --no-restore --version-suffix=${{ env.VERSION }} -p:Sign=true
-
-# #- name: Test
-# # run: dotnet test redmine-net-api.sln --no-restore --verbosity normal
-
-# - name: Pack
-# run: dotnet pack redmine-net-api.sln --configuration Release -o .\artifacts --include-symbols -p:SymbolPackageFormat=snupkg --no-build ${{ env.VERSION }}
-# if: runner.os != 'Windows'
-
-# - name: Pack Signed
-# run: dotnet pack redmine-net-api.sln --configuration Release -o .\artifacts --include-symbols -p:SymbolPackageFormat=snupkg --no-build ${{ env.VERSION }} -p:Sign=true
-# if: runner.os != 'Windows'
-
-# - name: Publish NuGet Packages
-# uses: actions/upload-artifact@master
-# with:
-# name: nupkg
-# path: .\artifacts\**\*.nupkg
-
-# - name: Publish Symbol Packages
-# uses: actions/upload-artifact@master
-# with:
-# name: snupkg
-# path: .\artifacts\**\*.snupkg
-
-# deploy:
-# runs-on: macOS-latest
-# needs: build
-# name: Deploy Packages
-# steps:
-# - name: Download Package artifact
-# uses: actions/download-artifact@master
-# with:
-# name: nupkg
-# - name: Download Package artifact
-# uses: actions/download-artifact@master
-# with:
-# name: snupkg
-
-# - name: Setup NuGet
-# uses: NuGet/setup-nuget@v1.0.2
-# with:
-# nuget-api-key: ${{ secrets.NUGET_API_KEY }}
-# nuget-version: latest
-
-# - name: Setup .NET Core SDK
-# uses: actions/setup-dotnet@v1
-# with:
-# dotnet-version: '3.1.x'
-
-# - name: Push to NuGet
-# run: dotnet nuget push nupkg\*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://nuget.org
-
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..a20b2882
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,178 @@
+name: 'Publish to NuGet'
+
+on:
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'The reason for running the workflow'
+ required: false
+ default: 'Manual publish'
+ version:
+ description: 'Version'
+ required: true
+ push:
+ tags:
+ - '[0-9]+.[0-9]+.[0-9]+*'
+
+env:
+ # Disable the .NET logo in the console output.
+ DOTNET_NOLOGO: true
+
+ # Disable sending usage data to Microsoft
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+
+ # Set working directory
+ PROJECT_PATH: ./src/redmine-net-api/redmine-net-api.csproj
+
+ # Configuration
+ CONFIGURATION: Release
+
+jobs:
+ check-tag-branch:
+ name: Check Tag and Master Branch hashes
+ # This job is based on replies in https://github.community/t/how-to-create-filter-on-both-tag-and-branch/16936/6
+ runs-on: ubuntu-latest
+ outputs:
+ ver: ${{ steps.set-version.outputs.VERSION }}
+ steps:
+ - name: Get tag commit hash
+ id: tag-commit-hash
+ run: |
+ hash=${{ github.sha }}
+ echo "{name}=tag-hash::${hash}" >> $GITHUB_OUTPUT
+ echo $hash
+
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ ref: master
+
+ - name: Get latest master commit hash
+ id: master-commit-hash
+ run: |
+ hash=$(git log -n1 --format=format:"%H")
+ echo "{name}=master-hash::${hash}" >> $GITHUB_OUTPUT
+ echo $hash
+
+ - name: Verify tag commit matches master commit - exit if they don't match
+ if: steps.tag-commit-hash.outputs.tag-hash != steps.master-commit-hash.outputs.master-hash
+ run: |
+ echo "Tag was not on the master branch. Exiting."
+ exit 1
+
+ - name: Get Dispatched Version
+ if: github.event_name == 'workflow_dispatch'
+ run: |
+ echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
+
+ - name: Get Tag Version
+ if: github.event_name == 'push'
+ run: |
+ echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
+
+ - name: Set Version
+ id: set-version
+ run: |
+ echo "VERSION=${{ env.VERSION }}" >> "$GITHUB_OUTPUT"
+
+ validate-version:
+ name: Validate Version
+ needs: check-tag-branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Get Version
+ run: echo "VERSION=${{ needs.check-tag-branch.outputs.ver }}" >> $GITHUB_ENV
+
+ - name: Display Version
+ run: echo "$VERSION"
+
+ - name: Check Version Is Declared
+ run: |
+ if [[ -z "$VERSION" ]]; then
+ echo "Version is not declared."
+ exit 1
+ fi
+
+ - name: Validate Version matches SemVer format
+ run: |
+ if [[ ! "$VERSION" =~ ^([0-9]+\.){2,3}[0-9]+(-[a-zA-Z0-9.-]+)*$ ]]; then
+ echo "The version does not match the SemVer format (X.Y.Z). Please provide a valid version."
+ exit 1
+ fi
+
+ call-build-and-test:
+ name: Call Build and Test
+ needs: validate-version
+ uses: ./.github/workflows/build-and-test.yml
+
+ pack:
+ name: Pack
+ needs: [check-tag-branch, validate-version, call-build-and-test]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Get Version
+ run: echo "VERSION=${{ needs.check-tag-branch.outputs.ver }}" >> $GITHUB_ENV
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: Setup .NET Core (global.json)
+ uses: actions/setup-dotnet@v4
+
+ - name: Display dotnet version
+ run: dotnet --version
+
+ - name: Install dependencies
+ run: dotnet restore "${{ env.PROJECT_PATH }}"
+
+ - name: π¦ Create the package
+ run: >-
+ dotnet pack "${{ env.PROJECT_PATH }}"
+ --output ./artifacts
+ --configuration "${{ env.CONFIGURATION }}"
+ --include-symbols
+ --include-source
+ -p:Version=$VERSION
+ -p:PackageVersion=$VERSION
+ -p:IncludeSymbols=true
+ -p:SymbolPackageFormat=snupkg
+
+ - name: π¦ Create the package - Signed
+ run: >-
+ dotnet pack "${{ env.PROJECT_PATH }}"
+ --output ./artifacts
+ --configuration "${{ env.CONFIGURATION }}"
+ --include-symbols
+ --include-source
+ -p:Version=$VERSION
+ -p:PackageVersion=$VERSION
+ -p:IncludeSymbols=true
+ -p:SymbolPackageFormat=snupkg
+ -p:Sign=true
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: artifacts
+ path: ./artifacts
+
+ publish:
+ name: Publish to Nuget
+ needs: pack
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: artifacts
+ path: ./artifacts
+
+ - name: Publish packages
+ run: >-
+ dotnet nuget push ./artifacts/**.nupkg
+ --source 'https://api.nuget.org/v3/index.json'
+ --api-key ${{secrets.NUGET_TOKEN}}
+ --skip-duplicate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 501cd134..a423cf3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## [v4.4.0]
+
+Added:
+* Added ParentTitle to wiki page
+
+Breaking Changes:
+
+* Changed ChangeSet revision type from int to string
+
## [v4.3.0]
Added:
diff --git a/Directory.Build.props b/Directory.Build.props
index 88b08f71..f2b9a699 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,39 +1,21 @@
-
-
-
- Adrian Popescu
- Redmine Api is a .NET rest client for Redmine.
- p.adi
- Adrian Popescu, 2011 - $([System.DateTime]::Now.Year.ToString())
- en-US
-
- redmine-api
- redmine-api-signed
- https://raw.githubusercontent.com/zapadi/redmine-net-api/master/logo.png
- logo.png
- LICENSE
- Apache-2.0
- https://github.com/zapadi/redmine-net-api
- true
- Redmine; REST; API; Client; .NET; Adrian Popescu;
- Redmine .NET API Client
- git
- https://github.com/zapadi/redmine-net-api
- ...
- Redmine .NET API Client
+
+ 12
+ strict
+ true
-
-
-
- false
- $(SolutionDir)/artifacts
+
+ 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 9398bf40..9a2add82 100755
--- a/README.md
+++ b/README.md
@@ -1,68 +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
+```
-## Thanks
-I would like to thank:
+## π§βπ» Usage Example
-* JetBrains for my Open Source ReSharper licence,
+```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!
+
+
+
+
+
+
+## π License
+
+This project is licensed under the [Apache License 2.0](LICENSE).
+
+
+## β Support
+
+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/appveyor.yml b/appveyor.yml
index c4095dd5..8fc91681 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,6 +1,6 @@
version: '{build}'
image:
- - Visual Studio 2019
+ - Visual Studio 2022
- Ubuntu
environment:
@@ -82,7 +82,7 @@ for:
- provider: NuGet
name: production
api_key:
- secure: fEZylRkHvyJqjgeQ+i9TfL/JOPjLKr43k+a8Oy5MIy54IkFC8ZECaEfskcWOyqcg
+ secure: W38N2nYNrxoik84zDowE+ShuVYKUyPA/fl4/8nYMBEXwcG+pSHVkt/2r6xQvQOaC
skip_symbols: true
on:
APPVEYOR_REPO_TAG: true
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index e7a53c7d..4cb6caf7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,8 +4,8 @@ services:
redmine:
ports:
- '8089:3000'
- image: 'redmine:4.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:11.1-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
new file mode 100644
index 00000000..1f044567
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "9.0.203",
+ "allowPrerelease": false,
+ "rollForward": "latestMajor"
+ }
+}
\ No newline at end of file
diff --git a/logo.png b/logo.png
old mode 100755
new mode 100644
index 0e88a5f4..83433adf
Binary files a/logo.png and b/logo.png differ
diff --git a/redmine-net-api.sln b/redmine-net-api.sln
index cd0dfab4..6e9f665f 100644
--- a/redmine-net-api.sln
+++ b/redmine-net-api.sln
@@ -12,22 +12,49 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "redmine-net-api.Tests", "te
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionFolder", "SolutionFolder", "{E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}"
ProjectSection(SolutionItems) = preProject
- appveyor.yml = appveyor.yml
CHANGELOG.md = CHANGELOG.md
CONTRIBUTING.md = CONTRIBUTING.md
- Directory.Build.props = Directory.Build.props
- docker-compose.yml = docker-compose.yml
ISSUE_TEMPLATE.md = ISSUE_TEMPLATE.md
LICENSE = LICENSE
- logo.png = logo.png
PULL_REQUEST_TEMPLATE.md = PULL_REQUEST_TEMPLATE.md
README.md = README.md
- redmine-net-api.snk = redmine-net-api.snk
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitActions", "GitActions", "{79119F8B-C468-4DC8-BE6F-6E7102BD2079}"
+ ProjectSection(SolutionItems) = preProject
+ .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml
+ .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml
+ .github\workflows\publish.yml = .github\workflows\publish.yml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AppVeyor", "AppVeyor", "{F20AEA6C-B957-4A83-9616-B91548B4C561}"
+ ProjectSection(SolutionItems) = preProject
+ appveyor.yml = appveyor.yml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Props", "Props", "{707B6A3F-1A2C-4EFE-851F-1DB0E68CFFFB}"
+ ProjectSection(SolutionItems) = preProject
+ Directory.Build.props = Directory.Build.props
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}"
+ ProjectSection(SolutionItems) = preProject
+ docker-compose.yml = docker-compose.yml
EndProjectSection
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Others", "Others", "{4ADECA2A-4D7B-4F05-85A2-0C0963A83689}"
+ ProjectSection(SolutionItems) = preProject
+ logo.png = logo.png
+ redmine-net-api.snk = redmine-net-api.snk
+ 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
@@ -46,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
@@ -53,6 +86,12 @@ Global
GlobalSection(NestedProjects) = preSolution
{0E6B9B72-445D-4E71-8D29-48C4A009AB03} = {0DFF4758-5C19-4D8F-BA6C-76E618323F6A}
{900EF0B3-0233-45DA-811F-4C59483E8452} = {F3F4278D-6271-4F77-BA88-41555D53CBD1}
+ {79119F8B-C468-4DC8-BE6F-6E7102BD2079} = {E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}
+ {F20AEA6C-B957-4A83-9616-B91548B4C561} = {E8C35EC2-DD90-46E8-9B63-84EFD5F2FDE3}
+ {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/redmine-net-api.sln.DotSettings b/redmine-net-api.sln.DotSettings
deleted file mode 100644
index 9134cb35..00000000
--- a/redmine-net-api.sln.DotSettings
+++ /dev/null
@@ -1,4 +0,0 @@
-ο»Ώ
- True
- True
- True
\ No newline at end of file
diff --git a/src/redmine-net-api/Async/RedmineManagerAsync.cs b/src/redmine-net-api/Async/RedmineManagerAsync.cs
deleted file mode 100644
index 441c6a3c..00000000
--- a/src/redmine-net-api/Async/RedmineManagerAsync.cs
+++ /dev/null
@@ -1,269 +0,0 @@
-
-#if NET20
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using Redmine.Net.Api.Serialization;
-using Redmine.Net.Api.Types;
-
-namespace Redmine.Net.Api.Async
-{
- ///
- ///
- ///
- public delegate void Task();
-
- ///
- ///
- ///
- /// The type of the resource.
- ///
- public delegate TRes Task();
-
- ///
- ///
- ///
- public static class RedmineManagerAsync
- {
- ///
- /// Gets the current user asynchronous.
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task GetCurrentUserAsync(this RedmineManager redmineManager,
- NameValueCollection parameters = null)
- {
- return delegate { return redmineManager.GetCurrentUser(parameters); };
- }
-
- ///
- /// Creates the or update wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- /// The wiki page.
- ///
- public static Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId,
- string pageName, WikiPage wikiPage)
- {
- return delegate { return redmineManager.CreateWikiPage(projectId, pageName, wikiPage); };
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId,
- string pageName, WikiPage wikiPage)
- {
- return delegate { redmineManager.UpdateWikiPage(projectId, pageName, wikiPage); };
- }
-
- ///
- /// Deletes the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- ///
- public static Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName)
- {
- return delegate { redmineManager.DeleteWikiPage(projectId, pageName); };
- }
-
- ///
- /// Gets the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// The parameters.
- /// Name of the page.
- /// The version.
- ///
- public static Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId,
- NameValueCollection parameters, string pageName, uint version = 0)
- {
- return delegate { return redmineManager.GetWikiPage(projectId, parameters, pageName, version); };
- }
-
- ///
- /// Gets all wiki pages asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- ///
- public static Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, string projectId)
- {
- return delegate { return redmineManager.GetAllWikiPages(projectId); };
- }
-
- ///
- /// Adds the user to group asynchronous.
- ///
- /// The redmine manager.
- /// The group identifier.
- /// The user identifier.
- ///
- public static Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- return delegate { redmineManager.AddUserToGroup(groupId, userId); };
- }
-
- ///
- /// Removes the user from group asynchronous.
- ///
- /// The redmine manager.
- /// The group identifier.
- /// The user identifier.
- ///
- public static Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- return delegate { redmineManager.RemoveUserFromGroup(groupId, userId); };
- }
-
- ///
- /// Adds the watcher to issue asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- return delegate { redmineManager.AddWatcherToIssue(issueId, userId); };
- }
-
- ///
- /// Removes the watcher from issue asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- return delegate { redmineManager.RemoveWatcherFromIssue(issueId, userId); };
- }
-
- ///
- /// Gets the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- /// The parameters.
- ///
- public static Task GetObjectAsync(this RedmineManager redmineManager, string id,
- NameValueCollection parameters) where T : class, new()
- {
- return delegate { return redmineManager.GetObject(id, parameters); };
- }
-
- ///
- /// Creates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The object.
- ///
- public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity) where T : class, new()
- {
- return CreateObjectAsync(redmineManager, entity, null);
- }
-
- ///
- /// Creates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The object.
- /// The owner identifier.
- ///
- public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity, string ownerId)
- where T : class, new()
- {
- return delegate { return redmineManager.CreateObject(entity, ownerId); };
- }
-
- ///
- /// Gets the paginated objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task> GetPaginatedObjectsAsync(this RedmineManager redmineManager,
- NameValueCollection parameters) where T : class, new()
- {
- return delegate { return redmineManager.GetPaginatedObjects(parameters); };
- }
-
- ///
- /// Gets the objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task> GetObjectsAsync(this RedmineManager redmineManager,
- NameValueCollection parameters) where T : class, new()
- {
- return delegate { return redmineManager.GetObjects(parameters); };
- }
-
- ///
- /// Updates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- /// The object.
- /// The project identifier.
- ///
- public static Task UpdateObjectAsync(this RedmineManager redmineManager, string id, T entity,
- string projectId = null) where T : class, new()
- {
- return delegate { redmineManager.UpdateObject(id, entity, projectId); };
- }
-
- ///
- /// Deletes the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- ///
- public static Task DeleteObjectAsync(this RedmineManager redmineManager, string id) where T : class, new()
- {
- return delegate { redmineManager.DeleteObject(id); };
- }
-
- ///
- /// Uploads the file asynchronous.
- ///
- /// The redmine manager.
- /// The data.
- ///
- public static Task UploadFileAsync(this RedmineManager redmineManager, byte[] data)
- {
- return delegate { return redmineManager.UploadFile(data); };
- }
-
- ///
- /// Downloads the file asynchronous.
- ///
- /// The redmine manager.
- /// The address.
- ///
- public static Task DownloadFileAsync(this RedmineManager redmineManager, string address)
- {
- return delegate { return redmineManager.DownloadFile(address); };
- }
- }
-}
-#endif
\ No newline at end of file
diff --git a/src/redmine-net-api/Async/RedmineManagerAsync40.cs b/src/redmine-net-api/Async/RedmineManagerAsync40.cs
deleted file mode 100644
index 3cf06d37..00000000
--- a/src/redmine-net-api/Async/RedmineManagerAsync40.cs
+++ /dev/null
@@ -1,291 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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 NET40
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using System.Threading;
-using System.Threading.Tasks;
-using Redmine.Net.Api.Types;
-using Redmine.Net.Api.Serialization;
-
-namespace Redmine.Net.Api.Async
-{
- ///
- ///
- ///
- public static class RedmineManagerAsync
- {
- ///
- /// Gets the current user asynchronous.
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task GetCurrentUserAsync(this RedmineManager redmineManager, NameValueCollection parameters = null)
- {
- return Task.Factory.StartNew(() => redmineManager.GetCurrentUser(parameters), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Creates the or update wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- /// The wiki page.
- ///
- public static Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage)
- {
- return Task.Factory.StartNew(() => redmineManager.CreateWikiPage(projectId, pageName, wikiPage), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage)
- {
- return Task.Factory.StartNew(() => redmineManager.UpdateWikiPage(projectId, pageName, wikiPage), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Deletes the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- ///
- public static Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName)
- {
- return Task.Factory.StartNew(() => redmineManager.DeleteWikiPage(projectId, pageName), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Gets the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// The parameters.
- /// Name of the page.
- /// The version.
- ///
- public static Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId, NameValueCollection parameters, string pageName, uint version = 0)
- {
- return Task.Factory.StartNew(() => redmineManager.GetWikiPage(projectId, parameters, pageName, version), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Gets all wiki pages asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- ///
- public static Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, string projectId)
- {
- return Task.Factory.StartNew(() => redmineManager.GetAllWikiPages(projectId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Adds the user to group asynchronous.
- ///
- /// The redmine manager.
- /// The group identifier.
- /// The user identifier.
- ///
- public static Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- return Task.Factory.StartNew(() => redmineManager.AddUserToGroup(groupId, userId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Removes the user from group asynchronous.
- ///
- /// The redmine manager.
- /// The group identifier.
- /// The user identifier.
- ///
- public static Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- return Task.Factory.StartNew(() => redmineManager.RemoveUserFromGroup(groupId, userId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Adds the watcher to issue asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- return Task.Factory.StartNew(() => redmineManager.AddWatcherToIssue(issueId, userId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Removes the watcher from issue asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- return Task.Factory.StartNew(() => redmineManager.RemoveWatcherFromIssue(issueId, userId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Gets the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- /// The parameters.
- ///
- public static Task GetObjectAsync(this RedmineManager redmineManager, string id, NameValueCollection parameters) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.GetObject(id, parameters), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Creates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The object.
- ///
- public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity) where T : class, new()
- {
- return CreateObjectAsync(redmineManager, entity, null);
- }
-
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static Task CountAsync(this RedmineManager redmineManager, params string[] include) where T : class, new()
- {
- return Task.Factory.StartNew(()=> redmineManager.Count(include), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static Task CountAsync(this RedmineManager redmineManager, NameValueCollection parameters) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.Count(parameters), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Creates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The object.
- /// The owner identifier.
- ///
- public static Task CreateObjectAsync(this RedmineManager redmineManager, T entity, string ownerId) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.CreateObject(entity, ownerId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Gets the paginated objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task> GetPaginatedObjectsAsync(this RedmineManager redmineManager, NameValueCollection parameters) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.GetPaginatedObjects(parameters), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Gets the objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static Task> GetObjectsAsync(this RedmineManager redmineManager, NameValueCollection parameters) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.GetObjects(parameters), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Updates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- /// The object.
- /// The project identifier.
- ///
- public static Task UpdateObjectAsync(this RedmineManager redmineManager, string id, T entity, string projectId = null) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.UpdateObject(id, entity, projectId), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Deletes the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- ///
- public static Task DeleteObjectAsync(this RedmineManager redmineManager, string id) where T : class, new()
- {
- return Task.Factory.StartNew(() => redmineManager.DeleteObject(id), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Uploads the file asynchronous.
- ///
- /// The redmine manager.
- /// The data.
- ///
- public static Task UploadFileAsync(this RedmineManager redmineManager, byte[] data)
- {
- return Task.Factory.StartNew(() => redmineManager.UploadFile(data), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
-
- ///
- /// Downloads the file asynchronous.
- ///
- /// The redmine manager.
- /// The address.
- ///
- public static Task DownloadFileAsync(this RedmineManager redmineManager, string address)
- {
- return Task.Factory.StartNew(() => redmineManager.DownloadFile(address), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
- }
- }
-}
-#endif
\ No newline at end of file
diff --git a/src/redmine-net-api/Async/RedmineManagerAsync45.cs b/src/redmine-net-api/Async/RedmineManagerAsync45.cs
deleted file mode 100644
index afb27ea9..00000000
--- a/src/redmine-net-api/Async/RedmineManagerAsync45.cs
+++ /dev/null
@@ -1,444 +0,0 @@
-ο»Ώ/*
-Copyright 2011 - 2021 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 || NET40)
-
-using System;
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using System.Globalization;
-using System.Net;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using Redmine.Net.Api.Extensions;
-using Redmine.Net.Api.Internals;
-using Redmine.Net.Api.Serialization;
-using Redmine.Net.Api.Types;
-
-namespace Redmine.Net.Api.Async
-{
- ///
- ///
- public static class RedmineManagerAsync
- {
- ///
- /// Gets the current user asynchronous.
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static async Task GetCurrentUserAsync(this RedmineManager redmineManager, NameValueCollection parameters = null)
- {
- var uri = UrlHelper.GetCurrentUserUrl(redmineManager);
- return await WebApiAsyncHelper.ExecuteDownload(redmineManager, uri, parameters).ConfigureAwait(false);
- }
-
- ///
- /// Creates the or update wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- /// The wiki page.
- ///
- public static async Task CreateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage)
- {
- var data = redmineManager.Serializer.Serialize(wikiPage);
- if (string.IsNullOrEmpty(data)) return null;
-
- var url = UrlHelper.GetWikiCreateOrUpdaterUrl(redmineManager, projectId, pageName);
-
- url = Uri.EscapeUriString(url);
-
- var response = await WebApiAsyncHelper.ExecuteUpload(redmineManager, url, HttpVerbs.PUT, data).ConfigureAwait(false);
- return redmineManager.Serializer.Deserialize(response);
- }
-
- ///
- /// Creates the or update wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- /// The wiki page.
- ///
- public static async Task UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage)
- {
- var data = redmineManager.Serializer.Serialize(wikiPage);
- if (string.IsNullOrEmpty(data))
- {
- return ;
- }
-
- var url = UrlHelper.GetWikiCreateOrUpdaterUrl(redmineManager, projectId, pageName);
-
- url = Uri.EscapeUriString(url);
-
- var response = await WebApiAsyncHelper.ExecuteUpload(redmineManager, url, HttpVerbs.PUT, data).ConfigureAwait(false);
- }
-
- ///
- /// Deletes the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// Name of the page.
- ///
- public static async Task DeleteWikiPageAsync(this RedmineManager redmineManager, string projectId,
- string pageName)
- {
- var uri = UrlHelper.GetDeleteWikiUrl(redmineManager, projectId, pageName);
- uri = Uri.EscapeUriString(uri);
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.DELETE, string.Empty).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.
- ///
- /// .
- ///
- public static async Task UploadFileAsync(this RedmineManager redmineManager, byte[] data)
- {
- var uri = UrlHelper.GetUploadFileUrl(redmineManager);
- return await WebApiAsyncHelper.ExecuteUploadFile(redmineManager, uri, data).ConfigureAwait(false);
- }
-
- ///
- /// Downloads the file asynchronous.
- ///
- /// The redmine manager.
- /// The address.
- ///
- public static async Task DownloadFileAsync(this RedmineManager redmineManager, string address)
- {
- return await WebApiAsyncHelper.ExecuteDownloadFile(redmineManager, address).ConfigureAwait(false);
- }
-
- ///
- /// Gets the wiki page asynchronous.
- ///
- /// The redmine manager.
- /// The project identifier.
- /// The parameters.
- /// Name of the page.
- /// The version.
- ///
- public static async Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId,
- NameValueCollection parameters, string pageName, uint version = 0)
- {
- var uri = UrlHelper.GetWikiPageUrl(redmineManager, projectId, pageName, version);
- uri = Uri.EscapeUriString(uri);
- return await WebApiAsyncHelper.ExecuteDownload(redmineManager, uri, parameters).ConfigureAwait(false);
- }
-
- ///
- /// Gets all wiki pages asynchronous.
- ///
- /// The redmine manager.
- /// The parameters.
- /// The project identifier.
- ///
- public static async Task> GetAllWikiPagesAsync(this RedmineManager redmineManager, NameValueCollection parameters, string projectId)
- {
- var uri = UrlHelper.GetWikisUrl(redmineManager, projectId);
- return await WebApiAsyncHelper.ExecuteDownloadList(redmineManager, uri, parameters).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.
- ///
- public static async Task AddUserToGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- var data = DataHelper.UserData(userId, redmineManager.MimeFormat);
- var uri = UrlHelper.GetAddUserToGroupUrl(redmineManager, groupId);
-
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.POST, data).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.
- ///
- public static async Task RemoveUserFromGroupAsync(this RedmineManager redmineManager, int groupId, int userId)
- {
- var uri = UrlHelper.GetRemoveUserFromGroupUrl(redmineManager, groupId, userId);
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.DELETE, string.Empty).ConfigureAwait(false);
- }
-
- ///
- /// Adds the watcher asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static async Task AddWatcherToIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- var data = DataHelper.UserData(userId, redmineManager.MimeFormat);
- var uri = UrlHelper.GetAddWatcherUrl(redmineManager, issueId);
-
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.POST, data).ConfigureAwait(false);
- }
-
- ///
- /// Removes the watcher asynchronous.
- ///
- /// The redmine manager.
- /// The issue identifier.
- /// The user identifier.
- ///
- public static async Task RemoveWatcherFromIssueAsync(this RedmineManager redmineManager, int issueId, int userId)
- {
- var uri = UrlHelper.GetRemoveWatcherUrl(redmineManager, issueId, userId);
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.DELETE, string.Empty).ConfigureAwait(false);
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task CountAsync(this RedmineManager redmineManager, params string[] include) where T : class, new()
- {
- var parameters = new NameValueCollection();
-
- if (include != null)
- {
- parameters.Add(RedmineKeys.INCLUDE, string.Join(",", include));
- }
-
- return await CountAsync(redmineManager,parameters).ConfigureAwait(false);
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task CountAsync(this RedmineManager redmineManager, NameValueCollection parameters) where T : class, new()
- {
- int totalCount = 0, pageSize = 1, offset = 0;
-
- if (parameters == null)
- {
- parameters = new NameValueCollection();
- }
-
- parameters.Set(RedmineKeys.LIMIT, pageSize.ToString(CultureInfo.InvariantCulture));
- parameters.Set(RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture));
-
- try
- {
- var tempResult = await GetPaginatedObjectsAsync(redmineManager,parameters).ConfigureAwait(false);
- if (tempResult != null)
- {
- totalCount = tempResult.TotalItems;
- }
- }
- catch (WebException wex)
- {
- wex.HandleWebException(redmineManager.Serializer);
- }
-
- return totalCount;
- }
-
-
- ///
- /// Gets the paginated objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static async Task> GetPaginatedObjectsAsync(this RedmineManager redmineManager,
- NameValueCollection parameters)
- where T : class, new()
- {
- var uri = UrlHelper.GetListUrl(redmineManager, parameters);
- return await WebApiAsyncHelper.ExecuteDownloadPaginatedList(redmineManager, uri, parameters).ConfigureAwait(false);
- }
-
- ///
- /// Gets the objects asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The parameters.
- ///
- public static async Task> GetObjectsAsync(this RedmineManager redmineManager, NameValueCollection parameters)
- where T : class, new()
- {
- int pageSize = 0, offset = 0;
- var isLimitSet = false;
- List resultList = null;
-
- if (parameters == null)
- {
- parameters = new NameValueCollection();
- }
- else
- {
- isLimitSet = int.TryParse(parameters[RedmineKeys.LIMIT], out pageSize);
- int.TryParse(parameters[RedmineKeys.OFFSET], out offset);
- }
-
- if (pageSize == default(int))
- {
- pageSize = redmineManager.PageSize > 0
- ? redmineManager.PageSize
- : RedmineManager.DEFAULT_PAGE_SIZE_VALUE;
- parameters.Set(RedmineKeys.LIMIT, pageSize.ToString(CultureInfo.InvariantCulture));
- }
- try
- {
- var hasOffset = RedmineManager.TypesWithOffset.ContainsKey(typeof(T));
- if (hasOffset)
- {
- var totalCount = 0;
- do
- {
- parameters.Set(RedmineKeys.OFFSET, offset.ToString(CultureInfo.InvariantCulture));
- var tempResult = await redmineManager.GetPaginatedObjectsAsync(parameters).ConfigureAwait(false);
- totalCount = isLimitSet ? pageSize : tempResult.TotalItems;
-
- if (tempResult?.Items != null)
- {
- if (resultList == null)
- {
- resultList = new List(tempResult.Items);
- }
- else
- {
- resultList.AddRange(tempResult.Items);
- }
- }
- offset += pageSize;
- } while (offset < totalCount);
- }
- else
- {
- var result = await redmineManager.GetPaginatedObjectsAsync(parameters).ConfigureAwait(false);
- if (result?.Items != null)
- {
- return new List(result.Items);
- }
- }
- }
- catch (WebException wex)
- {
- wex.HandleWebException(redmineManager.Serializer);
- }
- return resultList;
- }
-
- ///
- /// 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.
- ///
- public static async Task GetObjectAsync(this RedmineManager redmineManager, string id, NameValueCollection parameters)
- where T : class, new()
- {
- var uri = UrlHelper.GetGetUrl(redmineManager, id);
- return await WebApiAsyncHelper.ExecuteDownload(redmineManager, uri, parameters).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.
- ///
- public static async Task CreateObjectAsync(this RedmineManager redmineManager, T entity)
- where T : class, new()
- {
- return await CreateObjectAsync(redmineManager, entity, null).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.
- ///
- public static async Task CreateObjectAsync(this RedmineManager redmineManager, T entity, string ownerId)
- where T : class, new()
- {
- var uri = UrlHelper.GetCreateUrl(redmineManager, ownerId);
- var data = redmineManager.Serializer.Serialize(entity);
-
- var response = await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.POST, data).ConfigureAwait(false);
- return redmineManager.Serializer.Deserialize(response);
- }
-
- ///
- /// Updates the object asynchronous.
- ///
- ///
- /// The redmine manager.
- /// The identifier.
- /// The object.
- ///
- public static async Task UpdateObjectAsync(this RedmineManager redmineManager, string id, T entity)
- where T : class, new()
- {
- var uri = UrlHelper.GetUploadUrl(redmineManager, id);
- var data = redmineManager.Serializer.Serialize(entity);
- data = Regex.Replace(data, @"\r\n|\r|\n", "\r\n");
-
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.PUT, data).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
- ///
- public static async Task DeleteObjectAsync(this RedmineManager redmineManager, string id)
- where T : class, new()
- {
- var uri = UrlHelper.GetDeleteUrl(redmineManager, id);
- await WebApiAsyncHelper.ExecuteUpload(redmineManager, uri, HttpVerbs.DELETE, string.Empty).ConfigureAwait(false);
- }
- }
-}
-#endif
\ No newline at end of file
diff --git a/src/redmine-net-api/Authentication/IRedmineAuthentication.cs b/src/redmine-net-api/Authentication/IRedmineAuthentication.cs
new file mode 100644
index 00000000..d14d6fcf
--- /dev/null
+++ b/src/redmine-net-api/Authentication/IRedmineAuthentication.cs
@@ -0,0 +1,40 @@
+/*
+ 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;
+
+namespace Redmine.Net.Api.Authentication;
+
+///
+///
+///
+public interface IRedmineAuthentication
+{
+ ///
+ ///
+ ///
+ string AuthenticationType { get; }
+
+ ///
+ ///
+ ///
+ string Token { get; }
+
+ ///
+ ///
+ ///
+ ICredentials Credentials { get; }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs
new file mode 100644
index 00000000..c1d59744
--- /dev/null
+++ b/src/redmine-net-api/Authentication/RedmineApiKeyAuthentication.cs
@@ -0,0 +1,44 @@
+/*
+ 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;
+using Redmine.Net.Api.Extensions;
+
+namespace Redmine.Net.Api.Authentication;
+
+///
+///
+///
+public sealed class RedmineApiKeyAuthentication: IRedmineAuthentication
+{
+ ///
+ public string AuthenticationType { get; } = RedmineAuthenticationType.ApiKey.ToText();
+
+ ///
+ public string Token { get; init; }
+
+ ///
+ public ICredentials Credentials { get; init; }
+
+ ///
+ ///
+ ///
+ ///
+ public RedmineApiKeyAuthentication(string apiKey)
+ {
+ Token = apiKey;
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..810da00a
--- /dev/null
+++ b/src/redmine-net-api/Authentication/RedmineBasicAuthentication.cs
@@ -0,0 +1,52 @@
+/*
+ Copyright 2011 - 2025 Adrian Popescu
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+using System;
+using System.Net;
+using System.Text;
+using Redmine.Net.Api.Exceptions;
+using Redmine.Net.Api.Extensions;
+
+namespace Redmine.Net.Api.Authentication
+{
+ ///
+ ///
+ ///
+ public sealed class RedmineBasicAuthentication: IRedmineAuthentication
+ {
+ ///
+ public string AuthenticationType { get; } = RedmineAuthenticationType.Basic.ToText();
+
+ ///
+ public string Token { get; init; }
+
+ ///
+ public ICredentials Credentials { get; init; }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ public RedmineBasicAuthentication(string username, string password)
+ {
+ if (username == null) throw new RedmineException(nameof(username));
+ if (password == null) throw new RedmineException(nameof(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
new file mode 100644
index 00000000..d8518828
--- /dev/null
+++ b/src/redmine-net-api/Authentication/RedmineNoAuthentication.cs
@@ -0,0 +1,35 @@
+/*
+ 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;
+using Redmine.Net.Api.Extensions;
+
+namespace Redmine.Net.Api.Authentication;
+
+///
+///
+///
+public sealed class RedmineNoAuthentication: IRedmineAuthentication
+{
+ ///
+ public string AuthenticationType { get; } = RedmineAuthenticationType.NoAuthentication.ToText();
+
+ ///
+ public string Token { get; init; }
+
+ ///
+ public ICredentials Credentials { get; init; }
+}
\ No newline at end of file
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 6f92c86e..d95d24eb 100755
--- a/src/redmine-net-api/Types/IValue.cs
+++ b/src/redmine-net-api/Common/IValue.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2021 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/Serialization/PagedResults.cs b/src/redmine-net-api/Common/PagedResults.cs
similarity index 60%
rename from src/redmine-net-api/Serialization/PagedResults.cs
rename to src/redmine-net-api/Common/PagedResults.cs
index a6900f7c..af1e82fd 100644
--- a/src/redmine-net-api/Serialization/PagedResults.cs
+++ b/src/redmine-net-api/Common/PagedResults.cs
@@ -1,6 +1,22 @@
+/*
+ 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.Serialization
+namespace Redmine.Net.Api.Common
{
///
///
@@ -14,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;
}
@@ -59,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 86465c8d..00000000
--- a/src/redmine-net-api/Exceptions/ConflictException.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- private ConflictException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 8c13b5a3..00000000
--- a/src/redmine-net-api/Exceptions/ForbiddenException.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- private ForbiddenException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 29704631..00000000
--- a/src/redmine-net-api/Exceptions/InternalServerErrorException.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- private InternalServerErrorException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 77781629..00000000
--- a/src/redmine-net-api/Exceptions/NameResolutionFailureException.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- private NameResolutionFailureException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 7e4a914e..00000000
--- a/src/redmine-net-api/Exceptions/NotAcceptableException.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- private NotAcceptableException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 328f1a6d..00000000
--- a/src/redmine-net-api/Exceptions/NotFoundException.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- private NotFoundException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Exceptions/RedmineApiException.cs b/src/redmine-net-api/Exceptions/RedmineApiException.cs
new file mode 100644
index 00000000..fbe168ed
--- /dev/null
+++ b/src/redmine-net-api/Exceptions/RedmineApiException.cs
@@ -0,0 +1,208 @@
+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
+{
+ ///
+ ///
+ ///
+ [Serializable]
+ public class RedmineApiException : RedmineException
+ {
+ ///
+ /// Gets the error code parameter.
+ ///
+ /// 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 string Url { get; protected set; }
+
+ ///
+ ///
+ ///
+ public int? HttpStatusCode { get; protected set; }
+
+ ///
+ ///
+ ///
+ public RedmineApiException()
+ {
+ }
+
+ ///
+ ///
+ ///
+ ///
+ public RedmineApiException(string message) : base(message)
+ {
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ public RedmineApiException(string message, Exception innerException) : base(message, innerException)
+ {
+ var transientErrorResult = IsTransientError(InnerException);
+ IsTransient = transientErrorResult.IsTransient;
+ HttpStatusCode = transientErrorResult.StatusCode;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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
+
+ return new TransientErrorResult(false, null);
+ }
+#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 2f6ed3ef..95bff717 100644
--- a/src/redmine-net-api/Exceptions/RedmineException.cs
+++ b/src/redmine-net-api/Exceptions/RedmineException.cs
@@ -1,5 +1,5 @@
/*
- Copyright 2011 - 2021 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,64 +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)
- {
- }
-
+ ///
+ ///
+ 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;
}
+
+ 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 04af4c12..038a44df 100644
--- a/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs
+++ b/src/redmine-net-api/Exceptions/RedmineTimeoutException.cs
@@ -1,5 +1,5 @@
ο»Ώ/*
- Copyright 2011 - 2021 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,75 +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)
+ { }
///
- ///
+ /// 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)
- {
-
- }
+ /// 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 edb6f1d0..00000000
--- a/src/redmine-net-api/Exceptions/UnauthorizedException.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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)
- {
- }
-
- ///
- ///
- ///
- ///
- ///
- ///
- private UnauthorizedException(SerializationInfo serializationInfo, StreamingContext streamingContext):base(serializationInfo, streamingContext)
- {
-
- }
- }
-}
\ 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 1c70548e..00000000
--- a/src/redmine-net-api/Extensions/CollectionExtensions.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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;
- IList clonedList = new List();
- foreach (var item in listToClone)
- {
- clonedList.Add((T) item.Clone());
- }
- return clonedList;
- }
-
-
- ///
- /// Equalses the specified list to compare.
- ///
- ///
- /// 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 NET20
- if (list.Count != listToCompare.Count)
- {
- return false;
- }
- var index = 0;
- while (index < list.Count && (list[index] as T).Equals(listToCompare[index] as T))
- {
- index++;
- }
-
- return index == list.Count;
-#else
- var set = new HashSet(list);
- var setToCompare = new HashSet(listToCompare);
-
- return set.SetEquals(setToCompare);
-#endif
- }
-
- ///
- ///
- ///
- ///
- 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(",").Append(item);
- }
-
- sb[0] = '{';
- sb.Append("}");
-
- var str = sb.ToString();
-#if NET20
- sb = null;
-#else
- sb.Clear();
-#endif
- 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
new file mode 100644
index 00000000..fa0e57aa
--- /dev/null
+++ b/src/redmine-net-api/Extensions/IntExtensions.cs
@@ -0,0 +1,45 @@
+/*
+ Copyright 2011 - 2025 Adrian Popescu
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+namespace Redmine.Net.Api.Extensions;
+
+internal static class IntExtensions
+{
+ public static bool Between(this int val, int from, int to)
+ {
+ return val >= from && val <= to;
+ }
+
+ public static bool Greater(this int val, int than)
+ {
+ return val > than;
+ }
+
+ public static bool GreaterOrEqual(this int val, int than)
+ {
+ return val >= than;
+ }
+
+ public static bool Lower(this int val, int than)
+ {
+ return val < than;
+ }
+
+ public static bool LowerOrEqual(this int val, int than)
+ {
+ return val <= than;
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/ListExtensions.cs b/src/redmine-net-api/Extensions/ListExtensions.cs
new file mode 100755
index 00000000..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/NameValueCollectionExtensions.cs b/src/redmine-net-api/Extensions/NameValueCollectionExtensions.cs
deleted file mode 100644
index 3157ae68..00000000
--- a/src/redmine-net-api/Extensions/NameValueCollectionExtensions.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2017 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)
- {
- if (parameters == null)
- {
- return null;
- }
-
- var value = parameters.Get(parameterName);
-
- return value.IsNullOrWhiteSpace() ? null : value;
- }
-
- ///
- /// Gets the parameter value.
- ///
- /// The parameters.
- /// Name of the parameter.
- ///
- public static string GetValue(this NameValueCollection parameters, string parameterName)
- {
- if (parameters == null)
- {
- return null;
- }
-
- var value = parameters.Get(parameterName);
-
- return value.IsNullOrWhiteSpace() ? null : value;
- }
-
- ///
- ///
- ///
- ///
- ///
- public static string ToQueryString(this NameValueCollection requestParameters)
- {
- if (requestParameters == null || requestParameters.Count == 0)
- {
- return null;
- }
-
- var stringBuilder = new StringBuilder();
-
- for (var index = 0; index < requestParameters.Count; ++index)
- {
- stringBuilder
- .Append(requestParameters.AllKeys[index].ToString(CultureInfo.InvariantCulture))
- .Append("=")
- .Append(requestParameters[index].ToString(CultureInfo.InvariantCulture))
- .Append("&");
- }
-
- stringBuilder.Length -= 1;
-
- var queryString = stringBuilder.ToString();
-
- #if !(NET20)
- stringBuilder.Clear();
- #endif
- stringBuilder = null;
-
- return queryString;
- }
-
- }
-}
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs b/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs
new file mode 100644
index 00000000..71ebcf13
--- /dev/null
+++ b/src/redmine-net-api/Extensions/RedmineManagerExtensions.cs
@@ -0,0 +1,889 @@
+/*
+ Copyright 2011 - 2025 Adrian Popescu
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using Redmine.Net.Api.Common;
+#if !(NET20)
+using System.Threading;
+using System.Threading.Tasks;
+#endif
+using Redmine.Net.Api.Exceptions;
+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;
+
+namespace Redmine.Net.Api.Extensions
+{
+ ///
+ ///
+ ///
+ public static class RedmineManagerExtensions
+ {
+ ///
+ /// Archives 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 archived.
+ /// Additional request options to include in the API call.
+ public static void ArchiveProject(this RedmineManager redmineManager, string projectIdentifier,
+ RequestOptions requestOptions = null)
+ {
+ var uri = redmineManager.RedmineApiUrls.ProjectArchive(projectIdentifier);
+
+ 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);
+
+ _ = 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.
+ ///
+ /// 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)
+ {
+ throw new RedmineException("Argument news is null");
+ }
+
+ if (news.Title.IsNullOrWhiteSpace())
+ {
+ throw new RedmineException("News title cannot be blank");
+ }
+
+ var payload = redmineManager.Serializer.Serialize(news);
+
+ var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier);
+
+ var response = redmineManager.ApiClient.Create(uri, payload, requestOptions);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Retrieves the memberships associated with the specified project in Redmine.
+ ///
+ /// 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.GetPaginatedInternal(uri, requestOptions);
+
+ return response;
+ }
+
+ ///
+ /// Retrieves the list of files associated with a specific project in Redmine.
+ ///
+ /// 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.GetPaginatedInternal(uri, requestOptions);
+
+ return response;
+ }
+
+ ///
+ /// 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();
+
+ var response = redmineManager.ApiClient.Get(uri, requestOptions);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Retrieves the account details of the currently authenticated user.
+ ///
+ /// The instance of the RedmineManager used to perform the API call.
+ /// Optional configuration for the API request.
+ /// Returns the account details of the authenticated user as a MyAccount object.
+ public static MyAccount GetMyAccount(this RedmineManager redmineManager, RequestOptions requestOptions = null)
+ {
+ var uri = redmineManager.RedmineApiUrls.MyAccount();
+
+ var response = redmineManager.ApiClient.Get(uri, requestOptions);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Adds a watcher to a specific issue in Redmine using the specified issue ID and user ID.
+ ///
+ /// 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.ToInvariantString());
+
+ var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer);
+
+ redmineManager.ApiClient.Create(uri, payload, requestOptions);
+ }
+
+ ///
+ /// Removes a watcher from a specific issue in Redmine based on the specified issue identifier and user identifier.
+ ///
+ /// 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.ToInvariantString(), userId.ToInvariantString());
+
+ redmineManager.ApiClient.Delete(uri, requestOptions);
+ }
+
+ ///
+ /// Adds a user to a specified group in Redmine.
+ ///
+ /// 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.ToInvariantString());
+
+ var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer);
+
+ redmineManager.ApiClient.Create(uri, payload, requestOptions);
+ }
+
+ ///
+ /// Removes a user from a specified group in Redmine.
+ ///
+ /// 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.ToInvariantString(), userId.ToInvariantString());
+
+ redmineManager.ApiClient.Delete(uri, requestOptions);
+ }
+
+ ///
+ /// Updates a specified wiki page for a project in Redmine.
+ ///
+ /// 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);
+
+ if (string.IsNullOrEmpty(payload))
+ {
+ return;
+ }
+
+ var uri = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName);
+
+ redmineManager.ApiClient.Patch(uri, payload, requestOptions);
+ }
+
+ ///
+ /// 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);
+
+ if (string.IsNullOrEmpty(payload))
+ {
+ throw new RedmineException("The payload is empty");
+ }
+
+ var uri = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName);
+
+ var response = redmineManager.ApiClient.Update(uri, payload, requestOptions);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Retrieves a wiki page from a Redmine project using the specified project identifier and page name.
+ ///
+ /// 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.ToInvariantString());
+
+ var response = redmineManager.ApiClient.Get(uri, requestOptions);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Retrieves all wiki pages associated with the specified project.
+ ///
+ /// 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.GetInternal(uri, requestOptions);
+
+ return response;
+ }
+
+ ///
+ /// 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);
+
+ redmineManager.ApiClient.Delete(uri, requestOptions);
+ }
+
+ ///
+ /// Updates the attachment.
+ ///
+ ///
+ /// 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
+ {
+ {attachment.Id, attachment}
+ };
+
+ var data = redmineManager.Serializer.Serialize(attachments);
+
+ var uri = redmineManager.RedmineApiUrls.AttachmentUpdate(issueId.ToInvariantString());
+
+ redmineManager.ApiClient.Patch(uri, data, requestOptions);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ public static PagedResults Search(this RedmineManager redmineManager, string q, int limit = RedmineConstants.DEFAULT_PAGE_SIZE_VALUE, int offset = 0, SearchFilterBuilder searchFilter = null, string impersonateUserName = null)
+ {
+ var parameters = CreateSearchParameters(q, limit, offset, searchFilter);
+
+ var response = redmineManager.GetPaginated(new RequestOptions
+ {
+ QueryString = parameters,
+ ImpersonateUser = impersonateUserName
+ });
+
+ return response;
+ }
+
+ private static NameValueCollection CreateSearchParameters(string q, int limit, int offset, SearchFilterBuilder searchFilter)
+ {
+ if (q.IsNullOrWhiteSpace())
+ {
+ throw new ArgumentNullException(nameof(q));
+ }
+
+ var parameters = new NameValueCollection
+ {
+ {RedmineKeys.Q, q},
+ {RedmineKeys.LIMIT, limit.ToInvariantString()},
+ {RedmineKeys.OFFSET, offset.ToInvariantString()},
+ };
+
+ return searchFilter != null ? searchFilter.Build(parameters) : parameters;
+ }
+
+ #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 response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeToPagedResults(redmineManager.Serializer);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Additional request options to include in the API call.
+ ///
+ ///
+ ///
+ public static async Task AddProjectNewsAsync(this RedmineManager redmineManager, string projectIdentifier, News news, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ {
+ if (news == null)
+ {
+ throw new RedmineException("Argument news is null");
+ }
+
+ if (news.Title.IsNullOrWhiteSpace())
+ {
+ throw new RedmineException("News title cannot be blank");
+ }
+
+ var payload = redmineManager.Serializer.Serialize(news);
+
+ var uri = redmineManager.RedmineApiUrls.ProjectNews(projectIdentifier);
+
+ var response = await redmineManager.ApiClient.CreateAsync(uri, payload, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Additional request options to include in the API call.
+ ///
+ ///
+ ///
+ public static async Task> GetProjectMembershipsAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ {
+ var uri = redmineManager.RedmineApiUrls.ProjectMemberships(projectIdentifier);
+
+ var response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeToPagedResults(redmineManager.Serializer);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Additional request options to include in the API call.
+ ///
+ ///
+ ///
+ public static async Task> GetProjectFilesAsync(this RedmineManager redmineManager, string projectIdentifier, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ {
+ var uri = redmineManager.RedmineApiUrls.ProjectFilesFragment(projectIdentifier);
+
+ var response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeToPagedResults(redmineManager.Serializer);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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.GetPagedAsync(new RequestOptions()
+ {
+ QueryString = parameters
+ }, cancellationToken).ConfigureAwait(false);
+
+ 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)
+ {
+ var uri = redmineManager.RedmineApiUrls.CurrentUser();
+
+ var response = await redmineManager.ApiClient.GetAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Retrieves the account details of the currently authenticated user.
+ ///
+ /// The instance of the RedmineManager used to perform the API call.
+ /// Optional configuration for the API request.
+ ///
+ /// Returns the account details of the authenticated user as a MyAccount object.
+ public static async Task GetMyAccountAsync(this RedmineManager redmineManager, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ {
+ var uri = redmineManager.RedmineApiUrls.MyAccount();
+
+ var response = await redmineManager.ApiClient.GetAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Creates or updates wiki page asynchronous.
+ ///
+ /// 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 path = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName);
+
+ var response = await redmineManager.ApiClient.UpdateAsync(path, payload, 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 UpdateWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, WikiPage wikiPage, RequestOptions requestOptions = null, CancellationToken cancellationToken = default)
+ {
+ var payload = redmineManager.Serializer.Serialize(wikiPage);
+
+ if (string.IsNullOrEmpty(payload))
+ {
+ return;
+ }
+
+ var url = redmineManager.RedmineApiUrls.ProjectWikiPageUpdate(projectId, pageName);
+
+ await redmineManager.ApiClient.PatchAsync(url, payload, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deletes the wiki page asynchronous.
+ ///
+ /// 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);
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets the wiki page asynchronous.
+ ///
+ /// The redmine manager.
+ /// The project identifier.
+ /// Name of the page.
+ /// Additional request options to include in the API call.
+ /// The version.
+ ///
+ ///
+ public static async Task GetWikiPageAsync(this RedmineManager redmineManager, string projectId, string pageName, RequestOptions requestOptions = null, uint version = 0, CancellationToken cancellationToken = default)
+ {
+ var uri = version == 0
+ ? redmineManager.RedmineApiUrls.ProjectWikiPage(projectId, pageName)
+ : redmineManager.RedmineApiUrls.ProjectWikiPageVersion(projectId, pageName, version.ToInvariantString());
+
+ var response = await redmineManager.ApiClient.GetAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ return response.DeserializeTo(redmineManager.Serializer);
+ }
+
+ ///
+ /// Gets all wiki pages asynchronous.
+ ///
+ /// 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)
+ {
+ var uri = redmineManager.RedmineApiUrls.ProjectWikiIndex(projectId);
+
+ var response = await redmineManager.ApiClient.GetPagedAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+
+ var pages = response.DeserializeToPagedResults(redmineManager.Serializer);
+ return pages.Items as List;
+ }
+
+ ///
+ /// Adds an existing user to a group. This method does not block the calling thread.
+ ///
+ /// 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.ToInvariantString());
+
+ var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer);
+
+ await redmineManager.ApiClient.CreateAsync(uri, payload, requestOptions, cancellationToken).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.
+ /// 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.ToInvariantString(), userId.ToInvariantString());
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Adds the watcher asynchronous.
+ ///
+ /// 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.ToInvariantString());
+
+ var payload = SerializationHelper.SerializeUserId(userId, redmineManager.Serializer);
+
+ await redmineManager.ApiClient.CreateAsync(uri, payload, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Removes the watcher asynchronous.
+ ///
+ /// 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.ToInvariantString(), userId.ToInvariantString());
+
+ await redmineManager.ApiClient.DeleteAsync(uri, requestOptions, cancellationToken).ConfigureAwait(false);
+ }
+ #endif
+ }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs
new file mode 100644
index 00000000..016dd51f
--- /dev/null
+++ b/src/redmine-net-api/Extensions/SemaphoreSlimExtensions.cs
@@ -0,0 +1,35 @@
+/*
+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.Threading;
+using System.Threading.Tasks;
+
+namespace Redmine.Net.Api.Extensions;
+#if !(NET45_OR_GREATER || NETCOREAPP)
+internal static class SemaphoreSlimExtensions
+{
+
+ public static Task WaitAsync(this SemaphoreSlim semaphore, CancellationToken cancellationToken = default)
+ {
+ return Task.Factory.StartNew(() => semaphore.Wait(cancellationToken)
+ , CancellationToken.None
+ , TaskCreationOptions.None
+ , TaskScheduler.Default);
+ }
+}
+#endif
+#endif
diff --git a/src/redmine-net-api/Extensions/StringExtensions.cs b/src/redmine-net-api/Extensions/StringExtensions.cs
index 2ba360ed..a881f14d 100644
--- a/src/redmine-net-api/Extensions/StringExtensions.cs
+++ b/src/redmine-net-api/Extensions/StringExtensions.cs
@@ -1,6 +1,24 @@
+/*
+ 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.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Security;
+using System.Text.RegularExpressions;
namespace Redmine.Net.Api.Extensions
{
@@ -10,48 +28,46 @@ 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 NET20
if (value == null)
{
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;
}
}
+
return true;
-#else
- return string.IsNullOrWhiteSpace(value);
-#endif
}
///
- ///
+ /// 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())
+ if (text.IsNullOrWhiteSpace() || maximumLength < 1 || text.Length <= maximumLength)
{
- if (text.Length > maximumLength)
- {
- text = text.Substring(0, maximumLength);
- }
+ return text;
}
-
- return text;
+
+ #if (NET5_0_OR_GREATER)
+ return text.AsSpan()[..maximumLength].ToString();
+ #else
+ return text.Substring(0, maximumLength);
+ #endif
}
///
@@ -81,28 +97,107 @@ internal static SecureString ToSecureString(this string value)
return null;
}
- using (var rv = new SecureString())
+ var rv = new SecureString();
+
+ foreach (var ch in value)
{
- foreach (var c in value)
- {
- rv.AppendChar(c);
- }
-
- return rv;
+ 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))
+ {
return s;
+ }
- if (s.EndsWith("/", StringComparison.OrdinalIgnoreCase) || s.EndsWith("\"", StringComparison.OrdinalIgnoreCase))
+ #if (NET5_0_OR_GREATER)
+ if (s.EndsWith('/') || s.EndsWith('\\'))
+ {
+ return s.AsSpan()[..(s.Length - 1)].ToString();
+ }
+ #else
+ if (s.EndsWith("/", StringComparison.OrdinalIgnoreCase) || s.EndsWith(@"\", StringComparison.OrdinalIgnoreCase))
{
return s.Substring(0, s.Length - 1);
}
-
+ #endif
+
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
+ {
+ sbyte v => v.ToString(CultureInfo.InvariantCulture),
+ byte v => v.ToString(CultureInfo.InvariantCulture),
+ short v => v.ToString(CultureInfo.InvariantCulture),
+ ushort v => v.ToString(CultureInfo.InvariantCulture),
+ int v => v.ToString(CultureInfo.InvariantCulture),
+ uint v => v.ToString(CultureInfo.InvariantCulture),
+ long v => v.ToString(CultureInfo.InvariantCulture),
+ ulong v => v.ToString(CultureInfo.InvariantCulture),
+ float v => v.ToString("G7", CultureInfo.InvariantCulture), // Specify precision explicitly for backward compatibility
+ double v => v.ToString("G15", CultureInfo.InvariantCulture), // Specify precision explicitly for backward compatibility
+ decimal v => v.ToString(CultureInfo.InvariantCulture),
+ TimeSpan ts => ts.ToString(),
+ DateTime d => d.ToString(CultureInfo.InvariantCulture),
+ #pragma warning disable CA1308
+ 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
new file mode 100644
index 00000000..dd182846
--- /dev/null
+++ b/src/redmine-net-api/Extensions/TaskExtensions.cs
@@ -0,0 +1,60 @@
+/*
+Copyright 2011 - 2025 Adrian Popescu
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+#if !(NET20)
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Redmine.Net.Api.Extensions;
+
+internal static class TaskExtensions
+{
+ public static T GetAwaiterResult(this Task task)
+ {
+ return task.GetAwaiter().GetResult();
+ }
+
+ public static TResult Synchronize(Func> function)
+ {
+ return Task.Factory.StartNew(function, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default)
+ .Unwrap().GetAwaiter().GetResult();
+ }
+
+ public static void Synchronize(Func function)
+ {
+ Task.Factory.StartNew(function, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default)
+ .Unwrap().GetAwaiter().GetResult();
+ }
+
+ #if !(NET45_OR_GREATER || NETCOREAPP)
+ public static Task WhenAll(IEnumerable> tasks)
+ {
+ var clone = tasks.ToArray();
+
+ var x = Task.Factory.StartNew(() =>
+ {
+ Task.WaitAll(clone);
+ return clone.Select(t => t.Result).ToArray();
+ }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
+
+ return default;
+ }
+ #endif
+}
+#endif
\ No newline at end of file
diff --git a/src/redmine-net-api/Extensions/WebExtensions.cs b/src/redmine-net-api/Extensions/WebExtensions.cs
deleted file mode 100644
index 0bbbbcaf..00000000
--- a/src/redmine-net-api/Extensions/WebExtensions.cs
+++ /dev/null
@@ -1,137 +0,0 @@
-ο»Ώ/*
- Copyright 2011 - 2021 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 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:
- var errors = GetRedmineExceptions(exception.Response, serializer);
- var message = string.Empty;
-
- if (errors == null)
- throw new RedmineException($"Invalid or missing attribute parameters: {message}", innerException);
-
- foreach (var error in errors)
- {
- message = message + error.Info + Environment.NewLine;
- }
-
- throw new RedmineException("Invalid or missing attribute parameters: " + message, innerException);
-
- 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/Features/CallerArgumentExpressionAttribute.cs b/src/redmine-net-api/Features/CallerArgumentExpressionAttribute.cs
new file mode 100644
index 00000000..62165823
--- /dev/null
+++ b/src/redmine-net-api/Features/CallerArgumentExpressionAttribute.cs
@@ -0,0 +1,31 @@
+#if NET20_OR_GREATER
+#pragma warning disable
+#nullable enable annotations
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// ReSharper disable once CheckNamespace
+namespace System.Runtime.CompilerServices
+{
+ ///
+ /// An attribute that allows parameters to receive the expression of other parameters.
+ ///
+ [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
+ internal sealed class CallerArgumentExpressionAttribute : global::System.Attribute
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The condition parameter value.
+ public CallerArgumentExpressionAttribute(string parameterName)
+ {
+ ParameterName = parameterName;
+ }
+
+ ///
+ /// Gets the parameter name the expression is retrieved from.
+ ///
+ public string ParameterName { get; }
+ }
+}
+#endif
\ No newline at end of file
diff --git a/src/redmine-net-api/Features/IsExternalInit.cs b/src/redmine-net-api/Features/IsExternalInit.cs
new file mode 100644
index 00000000..0de2af11
--- /dev/null
+++ b/src/redmine-net-api/Features/IsExternalInit.cs
@@ -0,0 +1,21 @@
+#if NET20_OR_GREATER
+
+// ReSharper disable once CheckNamespace
+namespace System.Runtime.CompilerServices
+{
+ ///
+ /// Reserved to be used by the compiler for tracking metadata.
+ /// This class should not be used by developers in source code.
+ ///
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ internal static class IsExternalInit
+ {
+ }
+}
+
+#endif
+
+
+
+
+
diff --git a/src/redmine-net-api/Features/NotNullAttribute.cs b/src/redmine-net-api/Features/NotNullAttribute.cs
new file mode 100644
index 00000000..8aa30659
--- /dev/null
+++ b/src/redmine-net-api/Features/NotNullAttribute.cs
@@ -0,0 +1,25 @@
+#if NET20_OR_GREATER
+//
+#pragma warning disable
+#nullable enable annotations
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// ReSharper disable once CheckNamespace
+namespace System.Diagnostics.CodeAnalysis
+{
+ ///
+ /// Specifies that an output will not be null even if the corresponding type allows it.
+ /// Specifies that an input argument was not null when the call returns.
+ ///
+ [global::System.AttributeUsage(
+ global::System.AttributeTargets.Field |
+ global::System.AttributeTargets.Parameter |
+ global::System.AttributeTargets.Property |
+ global::System.AttributeTargets.ReturnValue,
+ Inherited = false)]
+ internal sealed class NotNullAttribute : global::System.Attribute
+ {
+ }
+}
+#endif
\ No newline at end of file
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/Http/Messages/RedmineApiRequest.cs b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs
new file mode 100644
index 00000000..bce2a22f
--- /dev/null
+++ b/src/redmine-net-api/Http/Messages/RedmineApiRequest.cs
@@ -0,0 +1,68 @@
+/*
+ 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.Http.Clients.WebClient;
+using Redmine.Net.Api.Http.Constants;
+
+namespace Redmine.Net.Api.Http.Messages;
+
+internal sealed class RedmineApiRequest
+{
+ ///
+ ///
+ ///
+ public RedmineApiRequestContent Content { get; set; }
+
+ ///
+ ///
+ ///
+ public string Method { get; set; } = HttpConstants.HttpVerbs.GET;
+
+ ///
+ ///
+ ///
+ public string RequestUri { get; set; }
+
+ ///
+ ///
+ ///
+ public NameValueCollection QueryString { get; set; }
+ ///
+ ///
+ ///
+ public string ImpersonateUser { get; set; }
+
+ ///
+ ///
+ ///
+ public string ContentType { get; set; }
+
+ ///
+ ///
+ ///
+ public string Accept { get; set; }
+ ///
+ ///
+ ///
+ public string UserAgent { get; set; }
+
+ ///
+ ///
+ ///
+ public Dictionary Headers { get; set; }
+}
\ No newline at end of file
diff --git a/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs
new file mode 100644
index 00000000..71fb1948
--- /dev/null
+++ b/src/redmine-net-api/Http/Messages/RedmineApiResponse.cs
@@ -0,0 +1,29 @@
+/*
+ Copyright 2011 - 2025 Adrian Popescu
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+using System.Collections.Specialized;
+using System.Net;
+
+namespace Redmine.Net.Api.Http.Messages;
+
+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/Logging/ILogger.cs b/src/redmine-net-api/Http/RedirectType.cs
old mode 100755
new mode 100644
similarity index 70%
rename from src/redmine-net-api/Logging/ILogger.cs
rename to src/redmine-net-api/Http/RedirectType.cs
index 5d483046..5bb7acf7
--- a/src/redmine-net-api/Logging/ILogger.cs
+++ b/src/redmine-net-api/Http/RedirectType.cs
@@ -1,5 +1,5 @@
-ο»Ώ/*
- Copyright 2011 - 2019 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,24 @@ You may obtain a copy of the License at
limitations under the License.
*/
-namespace Redmine.Net.Api.Logging
+namespace Redmine.Net.Api.Http
{
///
///
///
- public interface ILogger
+ internal enum RedirectType
{
///
- /// Logs the specified entry.
+ ///
///
- /// The entry.
- void Log(LogEntry entry);
+ None,
+ ///
+ ///
+ ///
+ OnlyHost,
+ ///
+ ///
+ ///
+ All
}
}
\ No newline at end of file
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