diff --git a/.azure-pipelines/ci-build.yml b/.azure-pipelines/ci-build.yml index a0c00a7a..cfdcde25 100644 --- a/.azure-pipelines/ci-build.yml +++ b/.azure-pipelines/ci-build.yml @@ -12,11 +12,9 @@ pr: include: - main - variables: buildPlatform: 'Any CPU' buildConfiguration: 'Release' - ProductBinPath: '$(Build.SourcesDirectory)\src\bin\$(BuildConfiguration)' resources: repositories: @@ -25,7 +23,6 @@ resources: name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release - extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: @@ -54,10 +51,19 @@ extends: inputs: targetType: filePath filePath: 'scripts\EnableSigning.ps1' - arguments: '-projectPath "$(Build.SourcesDirectory)/src/Microsoft.Kiota.Abstractions.csproj"' + arguments: '-projectPath "$(Build.SourcesDirectory)/Directory.Build.props"' pwsh: true enabled: true - + + - task: PowerShell@2 + displayName: 'Validate project version has been incremented' + condition: and(contains(variables['build.sourceBranch'], 'refs/heads/main'), succeeded()) + inputs: + targetType: 'filePath' + filePath: $(System.DefaultWorkingDirectory)\scripts\ValidateProjectVersionUpdated.ps1 + arguments: '-projectPath "$(Build.SourcesDirectory)/Directory.Build.props" -packageName "Microsoft.Kiota.Abstractions"' + pwsh: true + # Install the nuget tool. - task: NuGetToolInstaller@1 displayName: 'Install Nuget dependency manager' @@ -67,25 +73,25 @@ extends: # Build the Product project - task: DotNetCoreCLI@2 - displayName: 'Build Microsoft.Kiota.Abstractions' + displayName: 'Build projects in Microsoft.Kiota' inputs: - projects: '$(Build.SourcesDirectory)\Microsoft.Kiota.Abstractions.sln' + projects: '$(Build.SourcesDirectory)\Microsoft.Kiota.sln' arguments: '--configuration $(BuildConfiguration) --no-incremental' # Run the Unit test - task: DotNetCoreCLI@2 - displayName: 'Test Microsoft.Kiota.Abstractions' + displayName: 'Test projects in Microsoft.Kiota' inputs: command: test - projects: '$(Build.SourcesDirectory)\Microsoft.Kiota.Abstractions.sln' + projects: '$(Build.SourcesDirectory)\Microsoft.Kiota.sln' arguments: '--configuration $(BuildConfiguration) --no-build --framework net8.0' - - task: EsrpCodeSigning@2 + - task: EsrpCodeSigning@3 displayName: 'ESRP DLL Strong Name' inputs: ConnectedServiceName: 'microsoftgraph ESRP CodeSign DLL and NuGet (AKV)' - FolderPath: $(ProductBinPath) - Pattern: '**\*Microsoft.Kiota.Abstractions.dll' + FolderPath: src # This path should already omit test dlls as they exist in the `tests` folder + Pattern: '**\*Microsoft.Kiota.*.dll' signConfigType: inlineSignParams UseMinimatch: true inlineOperation: | @@ -107,14 +113,14 @@ extends: ] SessionTimeout: 20 - - task: EsrpCodeSigning@2 + - task: EsrpCodeSigning@3 displayName: 'ESRP DLL CodeSigning' inputs: ConnectedServiceName: 'microsoftgraph ESRP CodeSign DLL and NuGet (AKV)' - FolderPath: src + FolderPath: src # This path should already omit test dlls as they exist in the `tests` folder signConfigType: inlineSignParams UseMinimatch: true - Pattern: '**\*Microsoft.Kiota.Abstractions.dll' + Pattern: '**\*Microsoft.Kiota.*.dll' inlineOperation: | [ { @@ -156,21 +162,12 @@ extends: SessionTimeout: 20 # arguments are not parsed in DotNetCoreCLI@2 task for `pack` command, that's why we have a custom pack command here - - pwsh: dotnet pack $env:BUILD_SOURCESDIRECTORY/src/Microsoft.Kiota.Abstractions.csproj /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg --no-build --output $env:BUILD_ARTIFACTSTAGINGDIRECTORY --configuration $env:BUILD_CONFIGURATION + - pwsh: dotnet pack /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg --no-build --output $env:BUILD_ARTIFACTSTAGINGDIRECTORY --configuration $env:BUILD_CONFIGURATION env: BUILD_CONFIGURATION: $(BuildConfiguration) displayName: Dotnet pack - - task: PowerShell@2 - displayName: 'Validate project version has been incremented' - condition: and(contains(variables['build.sourceBranch'], 'refs/heads/main'), succeeded()) - inputs: - targetType: 'filePath' - filePath: $(System.DefaultWorkingDirectory)\scripts\ValidateProjectVersionUpdated.ps1 - arguments: '-projectPath "$(Build.SourcesDirectory)/src/Microsoft.Kiota.Abstractions.csproj" -packageName "Microsoft.Kiota.Abstractions"' - pwsh: true - - - task: EsrpCodeSigning@2 + - task: EsrpCodeSigning@3 displayName: 'ESRP CodeSigning Nuget Packages' inputs: ConnectedServiceName: 'microsoftgraph ESRP CodeSign DLL and NuGet (AKV)' @@ -215,7 +212,7 @@ extends: condition: and(contains(variables['build.sourceBranch'], 'refs/heads/main'), succeeded()) dependsOn: build jobs: - - deployment: deploy_dotnet_abstractions + - deployment: deploy_dotnet_kiota_libs dependsOn: [] environment: nuget-org strategy: @@ -243,10 +240,10 @@ extends: pwsh: true arguments: '-packageDirPath "$(Pipeline.Workspace)/"' - task: 1ES.PublishNuget@1 - displayName: 'NuGet push' + displayName: 'Push Nuget for Kiota libraries' inputs: command: push - packagesToPush: '$(Pipeline.Workspace)/Microsoft.Kiota.Abstractions.*.nupkg' + packagesToPush: '$(Pipeline.Workspace)/Microsoft.Kiota.*.nupkg' packageParentPath: '$(Pipeline.Workspace)' nuGetFeedType: external publishFeedCredentials: 'Kiota Nuget Connection' diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 24a4fced..1e2d881f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -16,7 +16,7 @@ jobs: build-and-test: runs-on: windows-latest env: - solutionName: Microsoft.Kiota.Abstractions.sln + solutionName: Microsoft.Kiota.sln steps: - uses: actions/checkout@v4 - name: Setup .NET @@ -30,10 +30,10 @@ jobs: - name: Restore dependencies run: dotnet restore ${{ env.solutionName }} - name: Build - run: dotnet build ${{ env.solutionName }} --no-restore -c Release /p:UseSharedCompilation=false + run: dotnet build ${{ env.solutionName }} --no-restore /p:UseSharedCompilation=false - name: Test for net462 - run: dotnet test ${{ env.solutionName }} --no-build --verbosity normal -c Release --framework net462 + run: dotnet test ${{ env.solutionName }} --no-build --verbosity normal --framework net462 - name: Test for net8.0 and collect coverage - run: dotnet test ${{ env.solutionName }} --no-build --verbosity normal -c Release /p:CollectCoverage=true /p:CoverletOutput=TestResults/ /p:CoverletOutputFormat=opencover --framework net8.0 + run: dotnet test ${{ env.solutionName }} --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutput=TestResults/ /p:CoverletOutputFormat=opencover --framework net8.0 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e65c9e6f..d041fd40 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -62,8 +62,8 @@ jobs: CoverletOutputFormat: "opencover" # https://github.com/microsoft/vstest/issues/4014#issuecomment-1307913682 shell: pwsh run: | - dotnet tool run dotnet-sonarscanner begin /k:"microsoft_kiota-abstractions-dotnet" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="Microsoft.Kiota.Abstractions.Tests/coverage.net8.0.opencover.xml" + dotnet tool run dotnet-sonarscanner begin /k:"microsoft_kiota-abstractions-dotnet" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="tests/abstractions/coverage.net8.0.opencover.xml,tests/authentication/azure/coverage.net8.0.opencover.xml,tests/http/httpClient/coverage.net8.0.opencover.xml,tests/serialization/json/coverage.net8.0.opencover.xml,tests/serialization/text/coverage.net8.0.opencover.xml,tests/serialization/form/coverage.net8.0.opencover.xml,tests/serialization/multipart/coverage.net8.0.opencover.xml" dotnet workload restore dotnet build - dotnet test Microsoft.Kiota.Abstractions.sln --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover --framework net8.0 + dotnet test Microsoft.Kiota.sln --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover --framework net8.0 dotnet tool run dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/.vscode/launch.json b/.vscode/launch.json index 13e514aa..c7beb3b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,14 +5,14 @@ // Use IntelliSense to find out which attributes exist for C# debugging // Use hover for the description of the existing attributes // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md - "name": ".NET Core Launch (console)", + "name": "Test - Abstractions", "type": "coreclr", "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/Microsoft.Kiota.Abstractions.Tests/bin/Debug/net8.0/Microsoft.Kiota.Abstractions.Tests.dll", + "program": "${workspaceFolder}/tests/abstractions/bin/Debug/net8.0/Microsoft.Kiota.Abstractions.Tests.dll", "args": [], - "cwd": "${workspaceFolder}/Microsoft.Kiota.Abstractions.Tests", + "cwd": "${workspaceFolder}", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "internalConsole", "stopAtEntry": false @@ -23,4 +23,4 @@ "request": "attach" } ] -} +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c187d22f..69e2bf25 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,7 +9,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/Microsoft.Kiota.Abstractions.sln", + "${workspaceFolder}/Microsoft.Kiota.sln", // Ask dotnet build to generate full paths for file names. "/property:GenerateFullPaths=true", // Do not generate summary otherwise it leads to duplicate errors in Problems panel @@ -24,7 +24,7 @@ "type": "process", "args": [ "test", - "${workspaceFolder}/Microsoft.Kiota.Abstractions.sln", + "${workspaceFolder}/Microsoft.Kiota.sln", // Ask dotnet build to generate full paths for file names. "/property:GenerateFullPaths=true", // Do not generate summary otherwise it leads to duplicate errors in Problems panel diff --git a/src/35MSSharedLib1024.snk b/35MSSharedLib1024.snk similarity index 100% rename from src/35MSSharedLib1024.snk rename to 35MSSharedLib1024.snk diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db3e570..a87eb821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,444 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.9.7] - 2024-07-04 +## [1.9.8] - 2024-07-08 -- Add helper methods to parse enums from strings. +- Migrated source of various libraries to mono repository at . -## [1.9.6] - 2024-06-12 +Refer to the following for earlier releases of libraries in the project. -- Add `IEnumerable` extension methods to remove LINQ dependency from generated code. - -### Added - -## [1.9.5] - 2024-06-03 - -### Added - -- Kiota's `Date` and `Time` types are interchangeable with the system `DateOnly` and `TimeOnly` types through implicit conversion operators. - -## [1.9.4] - 2024-05-31 - -### Changed - -- Fix MultipartBody serialization - -## [1.9.3] - 2024-05-28 - -### Changed - -- Fix time formatting for other cultures. - -## [1.9.2] - 2024-05-24 - -### Changed - -- Removes LINQ usages from product code. - -## [1.9.1] - 2024-05-13 - -### Changed - -- Removes GetAwaiter() call in KiotaSerializer.GetStringFromStream and makes it fully synchronous. - -## [1.9.0] - 2024-05-06 - -Adds asynchronous deserialization support and marks synchronous as obsolete. https://github.com/microsoft/kiota-abstractions-dotnet/issues/223 - -### Added - -- Added asynchronous deserialization methods (to KiotaJsonSerializer.Deserialization and KiotaSerializer.Deserialization). -- Added IAsyncParseNodeFactory interface to provide asynchronous version of GetRootParseNode: GetRootParseNodeAsync. -- Added ParseNodeFactoryRegistry.GetRootParseNodeAsync method. -- Added ParseNodeProxyFactory.GetRootParseNodeAsync method -- Adds async overloads for serialization helpers - -### Changed - -- Marked synchronous deserialization methods as obsolete. -- Marked IParseNodeFactory.GetRootParseNode as obsolete. -- Refactored ParseNodeFactoryRegistry.GetFactory to support both asynchronous (IAsyncParseNodeFactory) and synchronous (IParseNodeFactory) factories. - - -## [1.8.4] - 2024-04-19 - -- Bumps Std.UriTemplate to version 0.0.57 - -## [1.8.3] - 2024-04-18 - -- Have set the license expression on the nuget package rather than bundling in a file () - -## [1.8.2] - 2024-04-18 - -- Have made System.Diagnostics.DiagnosticSource only be included on Net Standard's TFM & net 5 () - -## [1.8.1] - 2024-03-26 - -### Changed - -- `MultipartBody` now supports an optional `fileName` parameter to specify the file name of the part. () - -## [1.8.0] - 2024-03-18 - -### Added - -- Added support for untyped nodes. () - -## [1.7.12] - 2024-03-08 - -### Changed - -- Upgrade `Std.UriTemplate` NuGet package to version 0.54. -- Add a unit test for emoji in URI template parameters. - -## [1.7.11] - 2024-02-26 - -### Changed - -- Updated IParseNode enum methods `DynamicallyAccessedMembersAttribute` to `PublicFields`. -- Fixed AOT compiler warnings from ILC. - -## [1.7.10] - 2024-02-26 - -### Changed - -- Added `net6.0` and `net8.0` as target frameworks. - -## [1.7.9] - 2024-02-05 - -### Changed - -- Added `DynamicallyAccessedMembers` annotation to `RequestInformation.Configure`. -- Fixes `IsTrimmable` property on the project. - -## [1.7.8] - 2024-02-02 - -### Changed - -- Updated `DynamicallyAccessedMembers` annotations for the `WriteCollectionOfEnumValues` method. - -## [1.7.7] - 2024-02-01 - -### Changed - -- Fixed AOT trimming warnings the URI template parameters resolution. [microsoft/kiota#4065](https://github.com/microsoft/kiota/issues/4065). - -## [1.7.6] - 2024-01-24 - -### Changed - -- Improve AllowedHost validator to throw an error if `https://` or `http://` prefix is present in a allowed host value.() - -## [1.7.5] - 2024-01-11 - -### Changed - -- Fixes missing query parameters when the parameter values are empty strings.() - -## [1.7.4] - 2024-01-09 - -### Changed - -- Fixed Method not found error due to conflicting dependencies by updating Std.UriTemplate dependency. -- Fixed unicode characters decoding in URI (). - -## [1.7.3] - 2023-11-30 - -### Changed - -- Fixed an issue where arrays of non-string types passed into the query parameter were not being converted to strings leading to Invalid cast exceptions. [microsoft/kiota#3354](https://github.com/microsoft/kiota/issues/3354) - -## [1.7.2] - 2023-11-14 - -### Added - -- Added support for dotnet 8. - -## [1.7.1] - 2023-11-13 - -### Changed - -- Fixed an issue where path and query parameters of enum type would not be expanded properly. [microsoft/kiota#3693](https://github.com/microsoft/kiota/issues/3693) - -## [1.7.0] - 2023-11-07 - -### Added - -- Added methods in request information to reduce the amount of code being generated. - -## [1.6.1] - 2023-11-02 - -### Changed - -- Fixes sanitization of Date and Time values in query and path parameters - -## [1.6.0] - 2023-10-31 - -### Added - -- Added helper methods to facilitate serialization and deserialization of models. [microsoft/kiota#3406](https://github.com/microsoft/kiota/issues/3406) - -## [1.5.0] - 2023-10-19 - -### Added - -- Added dotnet trimming support. - -## [1.4.0] - 2023-10-12 - -### Added - -- Added a method to set the request body content type in request information on binary payloads. - -## [1.3.5] - 2023-10-05 - -### Changed - -- Uses headers try add when setting the content type. - -## [1.3.4] - 2023-10-04 - -### Changed - -- Added a TryAdd method on the RequestHeaders Dictionary - -## [1.3.3] - 2023-09-25 - -### Changed - -- Removed the code that changed the first character of the query parameter name to lower case -- Added sanitization of guid values in query parameters - -## [1.3.2] - 2023-09-21 - -### Changed - -- Switched from `Tavis.UriTemplates` to `Std.UriTemplate` for URI template parsing. - -## [1.3.1] - 2023-08-08 - -### Fixed - -- Fixed a bug where excess duplicate subscriptions would be created on the same property in the backing store causing performance issues in some scenarios. Related to - -## [1.3.0] - 2023-08-01 - -### Added - -- Added support for multipart form data request body serialization. - -## [1.2.1] - 2023-07-03 - -### Fixed - -- Fixed a bug that caused the uri parameters not to be applied when the Uri template had a different casing than the parameter name that was used to set it. - -## [1.2.0] - 2023-06-28 - -### Added - -- Added an interface to mark composed type wrappers and facilitate serialization. - -## [1.1.4] - 2023-06-22 - -### Fixed - -- Use concurrent dictionary for In memory backing store registry to avoid race conditions. - -## [1.1.3] - 2023-06-13 - -### Fixed - -- Fixed a bug that would allow multiple "Content-Type", "Content-Length", and "Content-Location" header values. - -## [1.1.2] - 2023-05-17 - -### Changed - -- Fixes a bug in the InMemoryBackingStore that would not leave out properties in nested IBackedModel properties. - -## [1.1.1] - 2023-04-06 - -### Added - -- Adds the Response Headers to the ApiException class - -## [1.1.0] - 2023-03-22 - -### Added - -- Added a base request builder and a request configuration class to reduce the amount of code being generated. - -## [1.0.1] - 2023-03-10 - -### Changed - -- Update minimum version of [`System.Diagnostics.DiagnosticSource`](https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource) to `6.0.0`. - -## [1.0.0] - 2023-02-27 - -### Added - -- GA release - -### Changed - -## [1.0.0-rc.7] - 2023-02-03 - -### Added - -- Added a status code field to the API exception class. - -## [1.0.0-rc.6] - 2023-01-27 - -### Changed - -- Relaxed nullability tolerance when merging objects for composed types. - -## [1.0.0-rc.5] - 2023-01-26 - -### Changed - -- Use concurrent dictionary for serialization registry to avoid race conditions. - -### Changed - -## [1.0.0-rc.4] - 2023-01-17 - -### Changed - -- Adds support for nullable reference types - -## [1.0.0-rc.3] - 2023-01-09 - -### Changed - -- Adds a method to convert abstract requests to native requests in the request adapter interface. - -## [1.0.0-rc.2] - 2023-01-05 - -### Changed - -- Release candidate 2 -- Prevents sending requests with empty query parameter values - -## [1.0.0-rc.1] - 2022-12-15 - -### Changed - -- Release candidate 1 - -## [1.0.0-preview.19] - 2022-12-13 - -### Changed - -- Added support for multi-valued request headers - -## [1.0.0-preview.18] - 2022-11-22 - -### Changed - -- Bumps Tavis.UriTemplates to strongly name binary version - -## [1.0.0-preview.17] - 2022-11-11 - -### Changed - -- Fixes a bug in the InMemoryBackingstore that would not detect changes in nested collections of complex types that had backing stores - -## [1.0.0-preview.16] - 2022-10-28 - -### Changed - -- Fixed a bug where request bodies that are collections of single items would not serialize properly - -## [1.0.0-preview.15] - 2022-10-18 - -### Added - -- Adds an API key authentication provider. - -## [1.0.0-preview.14] - 2022-10-17 - -### Changed - -- Changes the ResponeHandler parameter in IRequestAdapter to be a RequestOption - -## [1.0.0-preview.13] - 2022-10-05 - -### Changed - -- Fixes a bug in the InMemoryBackingstore that would not detect changes in nested complex types and collections - -## [1.0.0-preview.12] - 2022-09-19 - -### Added - -- Added tracing support for request information content type. - -## [1.0.0-preview.11] - 2022-09-06 - -### Added - -- Added support for composed types serialization. - -## [1.0.0-preview.10] - 2022-08-11 - -### Changed - -- DateTime instances added to the url paths to default to ISO 8601 -- Adds explicit error message if the url template expects URI when accessing the URI from RequestInformation - -## [1.0.0-preview.9] - 2022-06-13 - -### Changed - -- Fixes a bug where the backing store would fail to be set in clients running .Net framework. - -## [1.0.0-preview.8] - 2022-05-11 - -### Added - -- Breaking: added an additional parameter to authentication methods to carry contextual information. - -## [1.0.0-preview.7] - 2022-05-11 - -### Added - -- Adds a method to support scalar request bodies - -## [1.0.0-preview.6] - 2022-04-22 - -### Added - -- Adds support for api surface revamp for query parameters - -## [1.0.0-preview.5] - 2022-04-12 - -### Changed - -- Breaking: Changes target runtime to netstandard2.0 - -## [1.0.0-preview.4] - 2022-04-06 - -### Added - -- Adds the ability to get the query parameter name from attribute. - -## [1.0.0-preview.3] - 2022-04-04 - -### Changed - -- Breaking: simplifies the field deserializers. - -## [1.0.0-preview.2] - 2022-03-29 - -### Added - -- Added support for vendor specific serialization in registries - -## [1.0.0-preview.1] - 2022-03-18 - -### Added - -- Initial Nuget release +1. [Abstractions](./src/abstractions/Changelog-old.md) +1. [Authentication - Azure](./src/authentication/azure/Changelog-old.md) +1. [Http - HttpClientLibrary](./src/http/httpClient/Changelog-old.md) +1. [Serialization - JSON](./src/serialization/json/Changelog-old.md) +1. [Serialization - FORM](./src/serialization/form/Changelog-old.md) +1. [Serialization - TEXT](./src/serialization/text/Changelog-old.md) +1. [Serialization - MULTIPART](./src/serialization/multipart/Changelog-old.md) diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..ea0d6d0b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,56 @@ + + + + 1.9.8 + preview + + false + + false + latest + enable + + + + Microsoft + © Microsoft Corporation. All rights reserved. + true + http://go.microsoft.com/fwlink/?LinkID=288890 + https://github.com/microsoft/kiota-dotnet + https://aka.ms/kiota/docs + + https://github.com/microsoft/kiota-dotnet/releases + + true + MIT + README.md + true + true + true + false + false + 35MSSharedLib1024.snk + true + $(NoWarn);NU5048;NETSDK1138 + true + true + true + + + + false + Library + + + + + True + + + + + + + + + \ No newline at end of file diff --git a/Microsoft.Kiota.Abstractions.sln b/Microsoft.Kiota.Abstractions.sln deleted file mode 100644 index f43cbea5..00000000 --- a/Microsoft.Kiota.Abstractions.sln +++ /dev/null @@ -1,62 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.10.34706.255 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions", "src\Microsoft.Kiota.Abstractions.csproj", "{DEE3A7F9-7951-403C-A90C-C182A381D6B6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{812D9C21-411D-4987-AB8E-C96185F01AD0}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - CHANGELOG.md = CHANGELOG.md - CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md - LICENSE = LICENSE - README.md = README.md - SECURITY.md = SECURITY.md - SUPPORT.md = SUPPORT.md - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions.Tests", "Microsoft.Kiota.Abstractions.Tests\Microsoft.Kiota.Abstractions.Tests.csproj", "{95C59CD7-B996-45A7-927D-4952E85A2BB8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|x64.ActiveCfg = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|x64.Build.0 = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|x86.ActiveCfg = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Debug|x86.Build.0 = Debug|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|Any CPU.Build.0 = Release|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|x64.ActiveCfg = Release|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|x64.Build.0 = Release|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|x86.ActiveCfg = Release|Any CPU - {DEE3A7F9-7951-403C-A90C-C182A381D6B6}.Release|x86.Build.0 = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|x64.ActiveCfg = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|x64.Build.0 = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|x86.ActiveCfg = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Debug|x86.Build.0 = Debug|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|Any CPU.Build.0 = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|x64.ActiveCfg = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|x64.Build.0 = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|x86.ActiveCfg = Release|Any CPU - {95C59CD7-B996-45A7-927D-4952E85A2BB8}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {B0E38E50-7BC8-4E28-BDAC-BF38F7AF2CD2} - EndGlobalSection -EndGlobal diff --git a/Microsoft.Kiota.sln b/Microsoft.Kiota.sln new file mode 100644 index 00000000..fa7d91e6 --- /dev/null +++ b/Microsoft.Kiota.sln @@ -0,0 +1,120 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.34706.255 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{812D9C21-411D-4987-AB8E-C96185F01AD0}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + CHANGELOG.md = CHANGELOG.md + CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md + LICENSE = LICENSE + README.md = README.md + SECURITY.md = SECURITY.md + SUPPORT.md = SUPPORT.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions.Tests", "tests\abstractions\Microsoft.Kiota.Abstractions.Tests.csproj", "{B112E9CF-055E-45FB-A32F-25CAB57936DB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Abstractions", "src\abstractions\Microsoft.Kiota.Abstractions.csproj", "{61B7F639-6456-41CA-B53E-492115888F84}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Authentication.Azure", "src\authentication\azure\Microsoft.Kiota.Authentication.Azure.csproj", "{ED943ED1-CC3E-41B9-BE79-C3C301D2267B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Authentication.Azure.Tests", "tests\authentication\azure\Microsoft.Kiota.Authentication.Azure.Tests.csproj", "{71161CE4-C748-4CD3-A5ED-A2B806B24360}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Http.HttpClientLibrary", "src\http\httpClient\Microsoft.Kiota.Http.HttpClientLibrary.csproj", "{B0D5E430-0A53-4A2A-B9B2-5CC83CC27652}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KiotaGenerated", "src\generated\KiotaGenerated.csproj", "{A3771749-0661-4A8D-AC16-FF338AB71FC4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Http.HttpClientLibrary.Tests", "tests\http\httpClient\Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj", "{644AA85A-6AA8-4DCC-AD97-13DC9660ED3A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Json.Tests", "tests\serialization\json\Microsoft.Kiota.Serialization.Json.Tests.csproj", "{841CFE44-B635-4BDC-A138-AF7510B795E9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Json", "src\serialization\json\Microsoft.Kiota.Serialization.Json.csproj", "{87168872-0B10-4BBB-9017-2501E76FE50A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Form", "src\serialization\form\Microsoft.Kiota.Serialization.Form.csproj", "{08C40934-39AB-4BD6-8306-7EF84122BF70}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Form.Tests", "tests\serialization\form\Microsoft.Kiota.Serialization.Form.Tests.csproj", "{E0C5DCE4-2CF5-4EA3-B351-3DD97081A9F4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Multipart.Tests", "tests\serialization\multipart\Microsoft.Kiota.Serialization.Multipart.Tests.csproj", "{E05E81CC-6C2F-4924-9E96-295447E923D4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Multipart", "src\serialization\multipart\Microsoft.Kiota.Serialization.Multipart.csproj", "{FB8B9F1D-213A-42EA-B9B8-981FA03847B0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Text", "src\serialization\text\Microsoft.Kiota.Serialization.Text.csproj", "{063737B2-4889-4789-A8EE-1727964510A1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Kiota.Serialization.Text.Tests", "tests\serialization\text\Microsoft.Kiota.Serialization.Text.Tests.csproj", "{5F6AC278-C4A4-4EED-A7D3-1750A4D6FD15}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B112E9CF-055E-45FB-A32F-25CAB57936DB}.Release|Any CPU.Build.0 = Release|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61B7F639-6456-41CA-B53E-492115888F84}.Release|Any CPU.Build.0 = Release|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED943ED1-CC3E-41B9-BE79-C3C301D2267B}.Release|Any CPU.Build.0 = Release|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71161CE4-C748-4CD3-A5ED-A2B806B24360}.Release|Any CPU.Build.0 = Release|Any CPU + {B0D5E430-0A53-4A2A-B9B2-5CC83CC27652}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0D5E430-0A53-4A2A-B9B2-5CC83CC27652}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0D5E430-0A53-4A2A-B9B2-5CC83CC27652}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0D5E430-0A53-4A2A-B9B2-5CC83CC27652}.Release|Any CPU.Build.0 = Release|Any CPU + {A3771749-0661-4A8D-AC16-FF338AB71FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3771749-0661-4A8D-AC16-FF338AB71FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3771749-0661-4A8D-AC16-FF338AB71FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3771749-0661-4A8D-AC16-FF338AB71FC4}.Release|Any CPU.Build.0 = Release|Any CPU + {644AA85A-6AA8-4DCC-AD97-13DC9660ED3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {644AA85A-6AA8-4DCC-AD97-13DC9660ED3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {644AA85A-6AA8-4DCC-AD97-13DC9660ED3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {644AA85A-6AA8-4DCC-AD97-13DC9660ED3A}.Release|Any CPU.Build.0 = Release|Any CPU + {841CFE44-B635-4BDC-A138-AF7510B795E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {841CFE44-B635-4BDC-A138-AF7510B795E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {841CFE44-B635-4BDC-A138-AF7510B795E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {841CFE44-B635-4BDC-A138-AF7510B795E9}.Release|Any CPU.Build.0 = Release|Any CPU + {87168872-0B10-4BBB-9017-2501E76FE50A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87168872-0B10-4BBB-9017-2501E76FE50A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87168872-0B10-4BBB-9017-2501E76FE50A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87168872-0B10-4BBB-9017-2501E76FE50A}.Release|Any CPU.Build.0 = Release|Any CPU + {08C40934-39AB-4BD6-8306-7EF84122BF70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08C40934-39AB-4BD6-8306-7EF84122BF70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08C40934-39AB-4BD6-8306-7EF84122BF70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08C40934-39AB-4BD6-8306-7EF84122BF70}.Release|Any CPU.Build.0 = Release|Any CPU + {E0C5DCE4-2CF5-4EA3-B351-3DD97081A9F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0C5DCE4-2CF5-4EA3-B351-3DD97081A9F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0C5DCE4-2CF5-4EA3-B351-3DD97081A9F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0C5DCE4-2CF5-4EA3-B351-3DD97081A9F4}.Release|Any CPU.Build.0 = Release|Any CPU + {E05E81CC-6C2F-4924-9E96-295447E923D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E05E81CC-6C2F-4924-9E96-295447E923D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E05E81CC-6C2F-4924-9E96-295447E923D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E05E81CC-6C2F-4924-9E96-295447E923D4}.Release|Any CPU.Build.0 = Release|Any CPU + {FB8B9F1D-213A-42EA-B9B8-981FA03847B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB8B9F1D-213A-42EA-B9B8-981FA03847B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB8B9F1D-213A-42EA-B9B8-981FA03847B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB8B9F1D-213A-42EA-B9B8-981FA03847B0}.Release|Any CPU.Build.0 = Release|Any CPU + {063737B2-4889-4789-A8EE-1727964510A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {063737B2-4889-4789-A8EE-1727964510A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {063737B2-4889-4789-A8EE-1727964510A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {063737B2-4889-4789-A8EE-1727964510A1}.Release|Any CPU.Build.0 = Release|Any CPU + {5F6AC278-C4A4-4EED-A7D3-1750A4D6FD15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F6AC278-C4A4-4EED-A7D3-1750A4D6FD15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F6AC278-C4A4-4EED-A7D3-1750A4D6FD15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F6AC278-C4A4-4EED-A7D3-1750A4D6FD15}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B0E38E50-7BC8-4E28-BDAC-BF38F7AF2CD2} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 3417c172..f9e16d4c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ -# Kiota Abstractions Library for dotnet +# Kiota Libraries for dotnet -[![Build, Test, CodeQl](https://github.com/microsoft/kiota-abstractions-dotnet/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/microsoft/kiota-abstractions-dotnet/actions/workflows/build-and-test.yml) [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Abstractions?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Abstractions/) +The Kiota libraries define the basic constructs for Kiota projects needed once an SDK has been generated from an OpenAPI definition and provide default implementations. -The Kiota abstractions Library for dotnet is the dotnet library defining the basic constructs Kiota projects need once an SDK has been generated from an OpenAPI definition. - -A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to the abstraction package to build and run. +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to the libraries to build and execute by providing default implementations for serialization, authentication and http transport. Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). -## Using the Abstractions Library +## Build Status + +[![Build, Test, CodeQl](https://github.com/microsoft/kiota-abstractions-dotnet/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/microsoft/kiota-abstractions-dotnet/actions/workflows/build-and-test.yml) + +## Libraries + +| Library | Nuget Release | +| ------ | ------ | +| [Abstractions](./src/abstractions/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Abstractions?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Abstractions/) | +| [Authentication - Azure](./src/authentication/azure/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Authentication.Azure?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Authentication.Azure/) | +| [Http - HttpClientLibrary](./src/http/httpClient/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Http.HttpClientLibrary?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Http.HttpClientLibrary/) | +| [Serialization - JSON](./src/serialization/json/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Serialization.Json?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Serialization.Json/) | +| [Serialization - FORM](./src/serialization/form/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Serialization.Form?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Serialization.Form/) | +| [Serialization - TEXT](./src/serialization/text/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Serialization.Text?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Serialization.Text/) | +| [Serialization - MULTIPART](./src/serialization/multipart/README.md) | [![NuGet Version](https://buildstats.info/nuget/Microsoft.Kiota.Serialization.Multipart?includePreReleases=true)](https://www.nuget.org/packages/Microsoft.Kiota.Serialization.Multipart/) | + +## Release notes -```shell -dotnet add package Microsoft.Kiota.Abstractions --prerelease -``` +The Kiota Libraries releases notes are available from [Roadmap](CHANGELOG.md) ## Debugging -If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Abstractions.sln** with Visual Studio. +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.sln** with Visual Studio. ## Contributing @@ -38,4 +50,4 @@ This project may contain trademarks or logos for projects, products, or services trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. -Any use of third-party trademarks or logos are subject to those third-party's policies. \ No newline at end of file +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/scripts/EnableSigning.ps1 b/scripts/EnableSigning.ps1 index b17ac820..ace3bf18 100644 --- a/scripts/EnableSigning.ps1 +++ b/scripts/EnableSigning.ps1 @@ -28,6 +28,12 @@ $delaySign.'#text' = "true" $signAssembly = $doc.SelectSingleNode("//SignAssembly"); $signAssembly.'#text' = "true" +# Set the AssemblyOriginatorKeyFile to an absolute path to resolve any path resolution issues. +# Assumption: The key file is in the same directory as the project file. +$dirName = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($projectPath)) +$assemblyOriginatorKeyFile = $doc.SelectSingleNode("//AssemblyOriginatorKeyFile"); +$assemblyOriginatorKeyFile.'#text' = Join-Path $dirName $assemblyOriginatorKeyFile.'#text' + $doc.Save($projectPath); -Write-Host "Updated the .csproj file so that we can sign the built assemblies." -ForegroundColor Green \ No newline at end of file +Write-Host "Updated the project file so that we can sign the built assemblies." -ForegroundColor Green \ No newline at end of file diff --git a/scripts/GetNugetPackageVersion.ps1 b/scripts/GetNugetPackageVersion.ps1 index b5866f8b..ae16d914 100644 --- a/scripts/GetNugetPackageVersion.ps1 +++ b/scripts/GetNugetPackageVersion.ps1 @@ -22,7 +22,7 @@ Param( Write-Host "Get the NuGet package version and set it in the global variable: VERSION_STRING" -ForegroundColor Magenta -$nugetPackageName = (Get-ChildItem (Join-Path $packageDirPath *.nupkg) -Exclude *.symbols.nupkg).Name +$nugetPackageName = Get-ChildItem (Join-Path $packageDirPath "Microsoft.Kiota.Abstractions*.nupkg") Write-Host "Found NuGet package: $nugetPackageName" -ForegroundColor Magenta diff --git a/src/Microsoft.Kiota.Abstractions.csproj b/src/Microsoft.Kiota.Abstractions.csproj deleted file mode 100644 index d7e7eecb..00000000 --- a/src/Microsoft.Kiota.Abstractions.csproj +++ /dev/null @@ -1,65 +0,0 @@ - - - - - Abstractions library for the Kiota generated SDKs in dotnet. - © Microsoft Corporation. All rights reserved. - Kiota Abstractions Library for dotnet - Microsoft - - netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 - latest - true - http://go.microsoft.com/fwlink/?LinkID=288890 - https://github.com/microsoft/kiota-abstractions-dotnet - https://aka.ms/kiota/docs - true - true - 1.9.7 - - true - false - false - 35MSSharedLib1024.snk - true - latest - enable - true - - - - https://github.com/microsoft/kiota-abstractions-dotnet/releases - - true - MIT - README.md - $(NoWarn);NU5048;NETSDK1138 - - - true - - - true - - - - - - - - - - - - - - true - - - - - True - - - - diff --git a/src/ApiClientBuilder.cs b/src/abstractions/ApiClientBuilder.cs similarity index 100% rename from src/ApiClientBuilder.cs rename to src/abstractions/ApiClientBuilder.cs diff --git a/src/ApiException.cs b/src/abstractions/ApiException.cs similarity index 100% rename from src/ApiException.cs rename to src/abstractions/ApiException.cs diff --git a/src/BaseRequestBuilder.cs b/src/abstractions/BaseRequestBuilder.cs similarity index 100% rename from src/BaseRequestBuilder.cs rename to src/abstractions/BaseRequestBuilder.cs diff --git a/src/abstractions/Changelog-old.md b/src/abstractions/Changelog-old.md new file mode 100644 index 00000000..1415a263 --- /dev/null +++ b/src/abstractions/Changelog-old.md @@ -0,0 +1,449 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.9.7] - 2024-07-04 + +- Add helper methods to parse enums from strings. + +## [1.9.6] - 2024-06-12 + +- Add `IEnumerable` extension methods to remove LINQ dependency from generated code. + +### Added + +## [1.9.5] - 2024-06-03 + +### Added + +- Kiota's `Date` and `Time` types are interchangeable with the system `DateOnly` and `TimeOnly` types through implicit conversion operators. + +## [1.9.4] - 2024-05-31 + +### Changed + +- Fix MultipartBody serialization + +## [1.9.3] - 2024-05-28 + +### Changed + +- Fix time formatting for other cultures. + +## [1.9.2] - 2024-05-24 + +### Changed + +- Removes LINQ usages from product code. + +## [1.9.1] - 2024-05-13 + +### Changed + +- Removes GetAwaiter() call in KiotaSerializer.GetStringFromStream and makes it fully synchronous. + +## [1.9.0] - 2024-05-06 + +Adds asynchronous deserialization support and marks synchronous as obsolete. + +### Added + +- Added asynchronous deserialization methods (to KiotaJsonSerializer.Deserialization and KiotaSerializer.Deserialization). +- Added IAsyncParseNodeFactory interface to provide asynchronous version of GetRootParseNode: GetRootParseNodeAsync. +- Added ParseNodeFactoryRegistry.GetRootParseNodeAsync method. +- Added ParseNodeProxyFactory.GetRootParseNodeAsync method +- Adds async overloads for serialization helpers + +### Changed + +- Marked synchronous deserialization methods as obsolete. +- Marked IParseNodeFactory.GetRootParseNode as obsolete. +- Refactored ParseNodeFactoryRegistry.GetFactory to support both asynchronous (IAsyncParseNodeFactory) and synchronous (IParseNodeFactory) factories. + +## [1.8.4] - 2024-04-19 + +- Bumps Std.UriTemplate to version 0.0.57 + +## [1.8.3] - 2024-04-18 + +- Have set the license expression on the nuget package rather than bundling in a file () + +## [1.8.2] - 2024-04-18 + +- Have made System.Diagnostics.DiagnosticSource only be included on Net Standard's TFM & net 5 () + +## [1.8.1] - 2024-03-26 + +### Changed + +- `MultipartBody` now supports an optional `fileName` parameter to specify the file name of the part. () + +## [1.8.0] - 2024-03-18 + +### Added + +- Added support for untyped nodes. () + +## [1.7.12] - 2024-03-08 + +### Changed + +- Upgrade `Std.UriTemplate` NuGet package to version 0.54. +- Add a unit test for emoji in URI template parameters. + +## [1.7.11] - 2024-02-26 + +### Changed + +- Updated IParseNode enum methods `DynamicallyAccessedMembersAttribute` to `PublicFields`. +- Fixed AOT compiler warnings from ILC. + +## [1.7.10] - 2024-02-26 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.7.9] - 2024-02-05 + +### Changed + +- Added `DynamicallyAccessedMembers` annotation to `RequestInformation.Configure`. +- Fixes `IsTrimmable` property on the project. + +## [1.7.8] - 2024-02-02 + +### Changed + +- Updated `DynamicallyAccessedMembers` annotations for the `WriteCollectionOfEnumValues` method. + +## [1.7.7] - 2024-02-01 + +### Changed + +- Fixed AOT trimming warnings the URI template parameters resolution. [microsoft/kiota#4065](https://github.com/microsoft/kiota/issues/4065). + +## [1.7.6] - 2024-01-24 + +### Changed + +- Improve AllowedHost validator to throw an error if `https://` or `http://` prefix is present in a allowed host value.() + +## [1.7.5] - 2024-01-11 + +### Changed + +- Fixes missing query parameters when the parameter values are empty strings.() + +## [1.7.4] - 2024-01-09 + +### Changed + +- Fixed Method not found error due to conflicting dependencies by updating Std.UriTemplate dependency. +- Fixed unicode characters decoding in URI (). + +## [1.7.3] - 2023-11-30 + +### Changed + +- Fixed an issue where arrays of non-string types passed into the query parameter were not being converted to strings leading to Invalid cast exceptions. [microsoft/kiota#3354](https://github.com/microsoft/kiota/issues/3354) + +## [1.7.2] - 2023-11-14 + +### Added + +- Added support for dotnet 8. + +## [1.7.1] - 2023-11-13 + +### Changed + +- Fixed an issue where path and query parameters of enum type would not be expanded properly. [microsoft/kiota#3693](https://github.com/microsoft/kiota/issues/3693) + +## [1.7.0] - 2023-11-07 + +### Added + +- Added methods in request information to reduce the amount of code being generated. + +## [1.6.1] - 2023-11-02 + +### Changed + +- Fixes sanitization of Date and Time values in query and path parameters + +## [1.6.0] - 2023-10-31 + +### Added + +- Added helper methods to facilitate serialization and deserialization of models. [microsoft/kiota#3406](https://github.com/microsoft/kiota/issues/3406) + +## [1.5.0] - 2023-10-19 + +### Added + +- Added dotnet trimming support. + +## [1.4.0] - 2023-10-12 + +### Added + +- Added a method to set the request body content type in request information on binary payloads. + +## [1.3.5] - 2023-10-05 + +### Changed + +- Uses headers try add when setting the content type. + +## [1.3.4] - 2023-10-04 + +### Changed + +- Added a TryAdd method on the RequestHeaders Dictionary + +## [1.3.3] - 2023-09-25 + +### Changed + +- Removed the code that changed the first character of the query parameter name to lower case +- Added sanitization of guid values in query parameters + +## [1.3.2] - 2023-09-21 + +### Changed + +- Switched from `Tavis.UriTemplates` to `Std.UriTemplate` for URI template parsing. + +## [1.3.1] - 2023-08-08 + +### Fixed + +- Fixed a bug where excess duplicate subscriptions would be created on the same property in the backing store causing performance issues in some scenarios. Related to + +## [1.3.0] - 2023-08-01 + +### Added + +- Added support for multipart form data request body serialization. + +## [1.2.1] - 2023-07-03 + +### Fixed + +- Fixed a bug that caused the uri parameters not to be applied when the Uri template had a different casing than the parameter name that was used to set it. + +## [1.2.0] - 2023-06-28 + +### Added + +- Added an interface to mark composed type wrappers and facilitate serialization. + +## [1.1.4] - 2023-06-22 + +### Fixed + +- Use concurrent dictionary for In memory backing store registry to avoid race conditions. + +## [1.1.3] - 2023-06-13 + +### Fixed + +- Fixed a bug that would allow multiple "Content-Type", "Content-Length", and "Content-Location" header values. + +## [1.1.2] - 2023-05-17 + +### Changed + +- Fixes a bug in the InMemoryBackingStore that would not leave out properties in nested IBackedModel properties. + +## [1.1.1] - 2023-04-06 + +### Added + +- Adds the Response Headers to the ApiException class + +## [1.1.0] - 2023-03-22 + +### Added + +- Added a base request builder and a request configuration class to reduce the amount of code being generated. + +## [1.0.1] - 2023-03-10 + +### Changed + +- Update minimum version of [`System.Diagnostics.DiagnosticSource`](https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource) to `6.0.0`. + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +### Changed + +## [1.0.0-rc.7] - 2023-02-03 + +### Added + +- Added a status code field to the API exception class. + +## [1.0.0-rc.6] - 2023-01-27 + +### Changed + +- Relaxed nullability tolerance when merging objects for composed types. + +## [1.0.0-rc.5] - 2023-01-26 + +### Changed + +- Use concurrent dictionary for serialization registry to avoid race conditions. + +### Changed + +## [1.0.0-rc.4] - 2023-01-17 + +### Changed + +- Adds support for nullable reference types + +## [1.0.0-rc.3] - 2023-01-09 + +### Changed + +- Adds a method to convert abstract requests to native requests in the request adapter interface. + +## [1.0.0-rc.2] - 2023-01-05 + +### Changed + +- Release candidate 2 +- Prevents sending requests with empty query parameter values + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +## [1.0.0-preview.19] - 2022-12-13 + +### Changed + +- Added support for multi-valued request headers + +## [1.0.0-preview.18] - 2022-11-22 + +### Changed + +- Bumps Tavis.UriTemplates to strongly name binary version + +## [1.0.0-preview.17] - 2022-11-11 + +### Changed + +- Fixes a bug in the InMemoryBackingstore that would not detect changes in nested collections of complex types that had backing stores + +## [1.0.0-preview.16] - 2022-10-28 + +### Changed + +- Fixed a bug where request bodies that are collections of single items would not serialize properly + +## [1.0.0-preview.15] - 2022-10-18 + +### Added + +- Adds an API key authentication provider. + +## [1.0.0-preview.14] - 2022-10-17 + +### Changed + +- Changes the ResponeHandler parameter in IRequestAdapter to be a RequestOption + +## [1.0.0-preview.13] - 2022-10-05 + +### Changed + +- Fixes a bug in the InMemoryBackingstore that would not detect changes in nested complex types and collections + +## [1.0.0-preview.12] - 2022-09-19 + +### Added + +- Added tracing support for request information content type. + +## [1.0.0-preview.11] - 2022-09-06 + +### Added + +- Added support for composed types serialization. + +## [1.0.0-preview.10] - 2022-08-11 + +### Changed + +- DateTime instances added to the url paths to default to ISO 8601 +- Adds explicit error message if the url template expects URI when accessing the URI from RequestInformation + +## [1.0.0-preview.9] - 2022-06-13 + +### Changed + +- Fixes a bug where the backing store would fail to be set in clients running .Net framework. + +## [1.0.0-preview.8] - 2022-05-11 + +### Added + +- Breaking: added an additional parameter to authentication methods to carry contextual information. + +## [1.0.0-preview.7] - 2022-05-11 + +### Added + +- Adds a method to support scalar request bodies + +## [1.0.0-preview.6] - 2022-04-22 + +### Added + +- Adds support for api surface revamp for query parameters + +## [1.0.0-preview.5] - 2022-04-12 + +### Changed + +- Breaking: Changes target runtime to netstandard2.0 + +## [1.0.0-preview.4] - 2022-04-06 + +### Added + +- Adds the ability to get the query parameter name from attribute. + +## [1.0.0-preview.3] - 2022-04-04 + +### Changed + +- Breaking: simplifies the field deserializers. + +## [1.0.0-preview.2] - 2022-03-29 + +### Added + +- Added support for vendor specific serialization in registries + +## [1.0.0-preview.1] - 2022-03-18 + +### Added + +- Initial Nuget release diff --git a/src/Date.cs b/src/abstractions/Date.cs similarity index 100% rename from src/Date.cs rename to src/abstractions/Date.cs diff --git a/src/DefaultQueryParameters.cs b/src/abstractions/DefaultQueryParameters.cs similarity index 100% rename from src/DefaultQueryParameters.cs rename to src/abstractions/DefaultQueryParameters.cs diff --git a/src/Helpers/EnumHelpers.cs b/src/abstractions/Helpers/EnumHelpers.cs similarity index 100% rename from src/Helpers/EnumHelpers.cs rename to src/abstractions/Helpers/EnumHelpers.cs diff --git a/src/IRequestAdapter.cs b/src/abstractions/IRequestAdapter.cs similarity index 100% rename from src/IRequestAdapter.cs rename to src/abstractions/IRequestAdapter.cs diff --git a/src/IRequestOption.cs b/src/abstractions/IRequestOption.cs similarity index 100% rename from src/IRequestOption.cs rename to src/abstractions/IRequestOption.cs diff --git a/src/IResponseHandler.cs b/src/abstractions/IResponseHandler.cs similarity index 100% rename from src/IResponseHandler.cs rename to src/abstractions/IResponseHandler.cs diff --git a/src/Method.cs b/src/abstractions/Method.cs similarity index 100% rename from src/Method.cs rename to src/abstractions/Method.cs diff --git a/src/abstractions/Microsoft.Kiota.Abstractions.csproj b/src/abstractions/Microsoft.Kiota.Abstractions.csproj new file mode 100644 index 00000000..999ed0c8 --- /dev/null +++ b/src/abstractions/Microsoft.Kiota.Abstractions.csproj @@ -0,0 +1,20 @@ + + + + + Abstractions library for the Kiota generated SDKs in dotnet. + Kiota Abstractions Library for dotnet + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + + + + + + + + + + + diff --git a/src/MultipartBody.cs b/src/abstractions/MultipartBody.cs similarity index 100% rename from src/MultipartBody.cs rename to src/abstractions/MultipartBody.cs diff --git a/src/NativeResponseHandler.cs b/src/abstractions/NativeResponseHandler.cs similarity index 100% rename from src/NativeResponseHandler.cs rename to src/abstractions/NativeResponseHandler.cs diff --git a/src/NativeResponseWrapper.cs b/src/abstractions/NativeResponseWrapper.cs similarity index 100% rename from src/NativeResponseWrapper.cs rename to src/abstractions/NativeResponseWrapper.cs diff --git a/src/QueryParameterAttribute.cs b/src/abstractions/QueryParameterAttribute.cs similarity index 100% rename from src/QueryParameterAttribute.cs rename to src/abstractions/QueryParameterAttribute.cs diff --git a/src/abstractions/README.md b/src/abstractions/README.md new file mode 100644 index 00000000..da885662 --- /dev/null +++ b/src/abstractions/README.md @@ -0,0 +1,39 @@ +# Kiota Abstractions Library for dotnet + +The Kiota abstractions Library for dotnet is the dotnet library defining the basic constructs Kiota projects need once an SDK has been generated from an OpenAPI definition. + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to the abstraction package to build and run. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Abstractions Library + +```shell +dotnet add package Microsoft.Kiota.Abstractions +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Abstractions.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/RequestConfiguration.cs b/src/abstractions/RequestConfiguration.cs similarity index 100% rename from src/RequestConfiguration.cs rename to src/abstractions/RequestConfiguration.cs diff --git a/src/RequestHeaders.cs b/src/abstractions/RequestHeaders.cs similarity index 100% rename from src/RequestHeaders.cs rename to src/abstractions/RequestHeaders.cs diff --git a/src/RequestInformation.cs b/src/abstractions/RequestInformation.cs similarity index 100% rename from src/RequestInformation.cs rename to src/abstractions/RequestInformation.cs diff --git a/src/ResponseHandlerOption.cs b/src/abstractions/ResponseHandlerOption.cs similarity index 100% rename from src/ResponseHandlerOption.cs rename to src/abstractions/ResponseHandlerOption.cs diff --git a/src/Time.cs b/src/abstractions/Time.cs similarity index 100% rename from src/Time.cs rename to src/abstractions/Time.cs diff --git a/src/authentication/AllowedHostsValidator.cs b/src/abstractions/authentication/AllowedHostsValidator.cs similarity index 100% rename from src/authentication/AllowedHostsValidator.cs rename to src/abstractions/authentication/AllowedHostsValidator.cs diff --git a/src/authentication/AnonymousAuthenticationProvider.cs b/src/abstractions/authentication/AnonymousAuthenticationProvider.cs similarity index 100% rename from src/authentication/AnonymousAuthenticationProvider.cs rename to src/abstractions/authentication/AnonymousAuthenticationProvider.cs diff --git a/src/authentication/ApiKeyAuthenticationProvider.cs b/src/abstractions/authentication/ApiKeyAuthenticationProvider.cs similarity index 100% rename from src/authentication/ApiKeyAuthenticationProvider.cs rename to src/abstractions/authentication/ApiKeyAuthenticationProvider.cs diff --git a/src/authentication/BaseBearerTokenAuthenticationProvider.cs b/src/abstractions/authentication/BaseBearerTokenAuthenticationProvider.cs similarity index 100% rename from src/authentication/BaseBearerTokenAuthenticationProvider.cs rename to src/abstractions/authentication/BaseBearerTokenAuthenticationProvider.cs diff --git a/src/authentication/IAccessTokenProvider.cs b/src/abstractions/authentication/IAccessTokenProvider.cs similarity index 100% rename from src/authentication/IAccessTokenProvider.cs rename to src/abstractions/authentication/IAccessTokenProvider.cs diff --git a/src/authentication/IAuthenticationProvider.cs b/src/abstractions/authentication/IAuthenticationProvider.cs similarity index 100% rename from src/authentication/IAuthenticationProvider.cs rename to src/abstractions/authentication/IAuthenticationProvider.cs diff --git a/src/extensions/IDictionaryExtensions.cs b/src/abstractions/extensions/IDictionaryExtensions.cs similarity index 100% rename from src/extensions/IDictionaryExtensions.cs rename to src/abstractions/extensions/IDictionaryExtensions.cs diff --git a/src/extensions/IEnumerableExtensions.cs b/src/abstractions/extensions/IEnumerableExtensions.cs similarity index 100% rename from src/extensions/IEnumerableExtensions.cs rename to src/abstractions/extensions/IEnumerableExtensions.cs diff --git a/src/extensions/StringExtensions.cs b/src/abstractions/extensions/StringExtensions.cs similarity index 100% rename from src/extensions/StringExtensions.cs rename to src/abstractions/extensions/StringExtensions.cs diff --git a/src/serialization/IAdditionalDataHolder.cs b/src/abstractions/serialization/IAdditionalDataHolder.cs similarity index 100% rename from src/serialization/IAdditionalDataHolder.cs rename to src/abstractions/serialization/IAdditionalDataHolder.cs diff --git a/src/serialization/IAsyncParseNodeFactory.cs b/src/abstractions/serialization/IAsyncParseNodeFactory.cs similarity index 100% rename from src/serialization/IAsyncParseNodeFactory.cs rename to src/abstractions/serialization/IAsyncParseNodeFactory.cs diff --git a/src/serialization/IComposedTypeWrapper.cs b/src/abstractions/serialization/IComposedTypeWrapper.cs similarity index 100% rename from src/serialization/IComposedTypeWrapper.cs rename to src/abstractions/serialization/IComposedTypeWrapper.cs diff --git a/src/serialization/IParsable.cs b/src/abstractions/serialization/IParsable.cs similarity index 100% rename from src/serialization/IParsable.cs rename to src/abstractions/serialization/IParsable.cs diff --git a/src/serialization/IParseNode.cs b/src/abstractions/serialization/IParseNode.cs similarity index 100% rename from src/serialization/IParseNode.cs rename to src/abstractions/serialization/IParseNode.cs diff --git a/src/serialization/IParseNodeFactory.cs b/src/abstractions/serialization/IParseNodeFactory.cs similarity index 100% rename from src/serialization/IParseNodeFactory.cs rename to src/abstractions/serialization/IParseNodeFactory.cs diff --git a/src/serialization/ISerializationWriter.cs b/src/abstractions/serialization/ISerializationWriter.cs similarity index 100% rename from src/serialization/ISerializationWriter.cs rename to src/abstractions/serialization/ISerializationWriter.cs diff --git a/src/serialization/ISerializationWriterFactory.cs b/src/abstractions/serialization/ISerializationWriterFactory.cs similarity index 100% rename from src/serialization/ISerializationWriterFactory.cs rename to src/abstractions/serialization/ISerializationWriterFactory.cs diff --git a/src/serialization/KiotaJsonSerializer.Deserialization.cs b/src/abstractions/serialization/KiotaJsonSerializer.Deserialization.cs similarity index 100% rename from src/serialization/KiotaJsonSerializer.Deserialization.cs rename to src/abstractions/serialization/KiotaJsonSerializer.Deserialization.cs diff --git a/src/serialization/KiotaJsonSerializer.Serialization.cs b/src/abstractions/serialization/KiotaJsonSerializer.Serialization.cs similarity index 100% rename from src/serialization/KiotaJsonSerializer.Serialization.cs rename to src/abstractions/serialization/KiotaJsonSerializer.Serialization.cs diff --git a/src/serialization/KiotaSerializer.Deserialization.cs b/src/abstractions/serialization/KiotaSerializer.Deserialization.cs similarity index 100% rename from src/serialization/KiotaSerializer.Deserialization.cs rename to src/abstractions/serialization/KiotaSerializer.Deserialization.cs diff --git a/src/serialization/KiotaSerializer.Serialization.cs b/src/abstractions/serialization/KiotaSerializer.Serialization.cs similarity index 100% rename from src/serialization/KiotaSerializer.Serialization.cs rename to src/abstractions/serialization/KiotaSerializer.Serialization.cs diff --git a/src/serialization/ParsableFactory.cs b/src/abstractions/serialization/ParsableFactory.cs similarity index 100% rename from src/serialization/ParsableFactory.cs rename to src/abstractions/serialization/ParsableFactory.cs diff --git a/src/serialization/ParseNodeFactoryRegistry.cs b/src/abstractions/serialization/ParseNodeFactoryRegistry.cs similarity index 100% rename from src/serialization/ParseNodeFactoryRegistry.cs rename to src/abstractions/serialization/ParseNodeFactoryRegistry.cs diff --git a/src/serialization/ParseNodeHelper.cs b/src/abstractions/serialization/ParseNodeHelper.cs similarity index 100% rename from src/serialization/ParseNodeHelper.cs rename to src/abstractions/serialization/ParseNodeHelper.cs diff --git a/src/serialization/ParseNodeProxyFactory.cs b/src/abstractions/serialization/ParseNodeProxyFactory.cs similarity index 100% rename from src/serialization/ParseNodeProxyFactory.cs rename to src/abstractions/serialization/ParseNodeProxyFactory.cs diff --git a/src/serialization/SerializationWriterFactoryRegistry.cs b/src/abstractions/serialization/SerializationWriterFactoryRegistry.cs similarity index 100% rename from src/serialization/SerializationWriterFactoryRegistry.cs rename to src/abstractions/serialization/SerializationWriterFactoryRegistry.cs diff --git a/src/serialization/SerializationWriterProxyFactory.cs b/src/abstractions/serialization/SerializationWriterProxyFactory.cs similarity index 100% rename from src/serialization/SerializationWriterProxyFactory.cs rename to src/abstractions/serialization/SerializationWriterProxyFactory.cs diff --git a/src/serialization/UntypedArray.cs b/src/abstractions/serialization/UntypedArray.cs similarity index 100% rename from src/serialization/UntypedArray.cs rename to src/abstractions/serialization/UntypedArray.cs diff --git a/src/serialization/UntypedBoolean.cs b/src/abstractions/serialization/UntypedBoolean.cs similarity index 100% rename from src/serialization/UntypedBoolean.cs rename to src/abstractions/serialization/UntypedBoolean.cs diff --git a/src/serialization/UntypedDecimal.cs b/src/abstractions/serialization/UntypedDecimal.cs similarity index 100% rename from src/serialization/UntypedDecimal.cs rename to src/abstractions/serialization/UntypedDecimal.cs diff --git a/src/serialization/UntypedDouble.cs b/src/abstractions/serialization/UntypedDouble.cs similarity index 100% rename from src/serialization/UntypedDouble.cs rename to src/abstractions/serialization/UntypedDouble.cs diff --git a/src/serialization/UntypedFloat.cs b/src/abstractions/serialization/UntypedFloat.cs similarity index 100% rename from src/serialization/UntypedFloat.cs rename to src/abstractions/serialization/UntypedFloat.cs diff --git a/src/serialization/UntypedInteger.cs b/src/abstractions/serialization/UntypedInteger.cs similarity index 100% rename from src/serialization/UntypedInteger.cs rename to src/abstractions/serialization/UntypedInteger.cs diff --git a/src/serialization/UntypedLong.cs b/src/abstractions/serialization/UntypedLong.cs similarity index 100% rename from src/serialization/UntypedLong.cs rename to src/abstractions/serialization/UntypedLong.cs diff --git a/src/serialization/UntypedNode.cs b/src/abstractions/serialization/UntypedNode.cs similarity index 100% rename from src/serialization/UntypedNode.cs rename to src/abstractions/serialization/UntypedNode.cs diff --git a/src/serialization/UntypedNull.cs b/src/abstractions/serialization/UntypedNull.cs similarity index 100% rename from src/serialization/UntypedNull.cs rename to src/abstractions/serialization/UntypedNull.cs diff --git a/src/serialization/UntypedObject.cs b/src/abstractions/serialization/UntypedObject.cs similarity index 100% rename from src/serialization/UntypedObject.cs rename to src/abstractions/serialization/UntypedObject.cs diff --git a/src/serialization/UntypedString.cs b/src/abstractions/serialization/UntypedString.cs similarity index 100% rename from src/serialization/UntypedString.cs rename to src/abstractions/serialization/UntypedString.cs diff --git a/src/store/BackingStoreFactorySingleton.cs b/src/abstractions/store/BackingStoreFactorySingleton.cs similarity index 100% rename from src/store/BackingStoreFactorySingleton.cs rename to src/abstractions/store/BackingStoreFactorySingleton.cs diff --git a/src/store/BackingStoreParseNodeFactory.cs b/src/abstractions/store/BackingStoreParseNodeFactory.cs similarity index 100% rename from src/store/BackingStoreParseNodeFactory.cs rename to src/abstractions/store/BackingStoreParseNodeFactory.cs diff --git a/src/store/BackingStoreSerializationWriterProxyFactory.cs b/src/abstractions/store/BackingStoreSerializationWriterProxyFactory.cs similarity index 100% rename from src/store/BackingStoreSerializationWriterProxyFactory.cs rename to src/abstractions/store/BackingStoreSerializationWriterProxyFactory.cs diff --git a/src/store/IBackedModel.cs b/src/abstractions/store/IBackedModel.cs similarity index 100% rename from src/store/IBackedModel.cs rename to src/abstractions/store/IBackedModel.cs diff --git a/src/store/IBackingStore.cs b/src/abstractions/store/IBackingStore.cs similarity index 100% rename from src/store/IBackingStore.cs rename to src/abstractions/store/IBackingStore.cs diff --git a/src/store/IBackingStoreFactory.cs b/src/abstractions/store/IBackingStoreFactory.cs similarity index 100% rename from src/store/IBackingStoreFactory.cs rename to src/abstractions/store/IBackingStoreFactory.cs diff --git a/src/store/InMemoryBackingStore.cs b/src/abstractions/store/InMemoryBackingStore.cs similarity index 100% rename from src/store/InMemoryBackingStore.cs rename to src/abstractions/store/InMemoryBackingStore.cs diff --git a/src/store/InMemoryBackingStoreFactory.cs b/src/abstractions/store/InMemoryBackingStoreFactory.cs similarity index 100% rename from src/store/InMemoryBackingStoreFactory.cs rename to src/abstractions/store/InMemoryBackingStoreFactory.cs diff --git a/src/authentication/azure/AzureIdentityAccessTokenProvider.cs b/src/authentication/azure/AzureIdentityAccessTokenProvider.cs new file mode 100644 index 00000000..b8a0abb0 --- /dev/null +++ b/src/authentication/azure/AzureIdentityAccessTokenProvider.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// Provides an implementation of for Azure.Identity. +/// +public class AzureIdentityAccessTokenProvider : IAccessTokenProvider, IDisposable +{ + private static readonly object BoxedTrue = true; + private static readonly object BoxedFalse = false; + + private readonly TokenCredential _credential; + private readonly ActivitySource _activitySource; + private readonly HashSet _scopes; + /// + public AllowedHostsValidator AllowedHostsValidator { get; protected set; } + + /// + /// The constructor + /// + /// The credential implementation to use to obtain the access token. + /// The list of allowed hosts for which to request access tokens. + /// The scopes to request the access token for. + /// The observability options to use for the authentication provider. + public AzureIdentityAccessTokenProvider(TokenCredential credential, string []? allowedHosts = null, ObservabilityOptions? observabilityOptions = null, params string[] scopes) + { + _credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + AllowedHostsValidator = new AllowedHostsValidator(allowedHosts); + + if(scopes == null) + _scopes = new(); + else + _scopes = new(scopes, StringComparer.OrdinalIgnoreCase); + + _activitySource = new((observabilityOptions ?? new()).TracerInstrumentationName); + } + + private const string ClaimsKey = "claims"; + + private readonly HashSet _localHostStrings = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "localhost", + "[::1]", + "::1", + "127.0.0.1" + }; + + /// + public async Task GetAuthorizationTokenAsync(Uri uri, Dictionary? additionalAuthenticationContext = default, CancellationToken cancellationToken = default) + { + using var span = _activitySource?.StartActivity(nameof(GetAuthorizationTokenAsync)); + if(!AllowedHostsValidator.IsUrlHostValid(uri)) { + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedFalse); + return string.Empty; + } + + if(!uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && !_localHostStrings.Contains(uri.Host)) { + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedFalse); + throw new ArgumentException("Only https is supported"); + } + span?.SetTag("com.microsoft.kiota.authentication.is_url_valid", BoxedTrue); + + string? decodedClaim = null; + if (additionalAuthenticationContext is not null && + additionalAuthenticationContext.ContainsKey(ClaimsKey) && + additionalAuthenticationContext[ClaimsKey] is string claims) { + span?.SetTag("com.microsoft.kiota.authentication.additional_claims_provided", BoxedTrue); + var decodedBase64Bytes = Convert.FromBase64String(claims); + decodedClaim = Encoding.UTF8.GetString(decodedBase64Bytes); + } else + span?.SetTag("com.microsoft.kiota.authentication.additional_claims_provided", BoxedFalse); + + string[] scopes; + if (_scopes.Count > 0) { + scopes = new string[_scopes.Count]; + _scopes.CopyTo(scopes); + } else + scopes = [ $"{uri.Scheme}://{uri.Host}/.default" ]; + span?.SetTag("com.microsoft.kiota.authentication.scopes", string.Join(",", scopes)); + + var result = await _credential.GetTokenAsync(new TokenRequestContext(scopes, claims: decodedClaim), cancellationToken).ConfigureAwait(false); + return result.Token; + } + + /// + public void Dispose() + { + _activitySource?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/authentication/azure/AzureIdentityAuthenticationProvider.cs b/src/authentication/azure/AzureIdentityAuthenticationProvider.cs new file mode 100644 index 00000000..d5d083e4 --- /dev/null +++ b/src/authentication/azure/AzureIdentityAuthenticationProvider.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Azure.Core; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// The implementation that supports implementations of from Azure.Identity. +/// +public class AzureIdentityAuthenticationProvider : BaseBearerTokenAuthenticationProvider +{ + /// + /// The constructor + /// + /// The credential implementation to use to obtain the access token. + /// The list of allowed hosts for which to request access tokens. + /// The scopes to request the access token for. + /// The observability options to use for the authentication provider. + public AzureIdentityAuthenticationProvider(TokenCredential credential, string[]? allowedHosts = null, ObservabilityOptions? observabilityOptions = null, params string[] scopes) + : base(new AzureIdentityAccessTokenProvider(credential, allowedHosts, observabilityOptions, scopes)) + { + } +} diff --git a/src/authentication/azure/Changelog-old.md b/src/authentication/azure/Changelog-old.md new file mode 100644 index 00000000..cb210706 --- /dev/null +++ b/src/authentication/azure/Changelog-old.md @@ -0,0 +1,130 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.1.7] - 2024-05-24 + +### Changed + +- Remove all LINQ usage from product code + +## [1.1.6] - 2024-05-23 + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [microsoft/kiota-http-dotnet#256](https://github.com/microsoft/kiota-http-dotnet/issues/258) + +## [1.1.5] - 2024-04-19 + +- Have made System.Diagnostics.DiagnosticSource only be included on Net Standard's TFM & net 5 () + +## [1.1.4] - 2024-02-26 + +### Added + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.1.3] - 2024-02-05 + +### Changed + +- Fixes `IsTrimmable` property on the project. + +## [1.1.2] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.1.1] - 2023-11-03 + +### Added + +- Allow http scheme on localhost. + +## [1.1.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +## [1.0.3] - 2023-06-26 + +### Changed + +- Fix unwanted scopes collection modification in AzureIdentityAccessTokenProvider ([#73]([https://github.com/microsoft/kiota-authentication-azure-dotnet/issues/93])). +- Add missing ConfigureAwait(false) to GetTokenAsync call. +- Replaced true/false values in SetTag method calls with pre-initialized values to prevent boxing. + +## [1.0.2] - 2023-03-24 + +### Changed + +- Update minimum version of [`Azure.Core`]([https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource](https://www.nuget.org/packages/Azure.Core)) to `1.3.0` to fix Azure.Blob storage issues. + +### Changed + +## [1.0.1] - 2023-03-10 + +### Changed + +- Update minimum version of [`System.Diagnostics.DiagnosticSource`](https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource) to `6.0.0`. + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +## [1.0.0-rc.3] - 2023-01-17 + +### Added + +- Adds support for nullabe reference types + +## [1.0.0-rc.2] - 2023-01-16 + +### Changed + +- Removed microsoft graph specific constants to make usage easier for other MIP protected APIs. + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +## [1.0.0-preview.5] - 2022-12-12 + +### Changed + +- Updates abstractions reference to add support for multi-valued headers. + +## [1.0.0-preview.4] - 2022-09-19 + +### Added + +- Added tracing through Open Telemetry. + +## [1.0.0-preview.3] - 2022-05-17 + +### Added + +- Added support for continuous access evaluation. + +## [1.0.0-preview.2] - 2022-04-12 + +### Changed + +- Breaking: Changes target runtime to netstandard2.0 + +## [1.0.0-preview.1] - 2022-03-18 + +### Added + +- Initial Nuget release diff --git a/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj b/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj new file mode 100644 index 00000000..c300e798 --- /dev/null +++ b/src/authentication/azure/Microsoft.Kiota.Authentication.Azure.csproj @@ -0,0 +1,21 @@ + + + + + Kiota authentication provider implementation with Azure Identity. + Kiota Azure Identity Authentication Library for dotnet + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + + + + + + + + + + + + diff --git a/src/authentication/azure/ObservabilityOptions.cs b/src/authentication/azure/ObservabilityOptions.cs new file mode 100644 index 00000000..28c31cd4 --- /dev/null +++ b/src/authentication/azure/ObservabilityOptions.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.Kiota.Authentication.Azure; +/// +/// Holds the tracing, metrics and logging configuration for the authentication provider +/// +public class ObservabilityOptions { + private static readonly Lazy _name = new Lazy(() => typeof(ObservabilityOptions).Namespace!); + /// + /// Gets the observability name to use for the tracer + /// + public string TracerInstrumentationName => _name.Value; +} diff --git a/src/authentication/azure/README.md b/src/authentication/azure/README.md new file mode 100644 index 00000000..fc73bbdb --- /dev/null +++ b/src/authentication/azure/README.md @@ -0,0 +1,39 @@ +# Kiota Azure Identity authentication provider library for dotnet + +The Kiota Azure Identity authentication provider library for dotnet is the authentication provider implementation with [Azure.Identity](https://docs.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme). + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a authentication provider library to authenticate HTTP requests to an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using Azure Identity authentication provider library for dotnet + +```shell +dotnet add package Microsoft.Kiota.Authentication.Azure +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Authentication.Azure.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/generated/KiotaGenerated.csproj b/src/generated/KiotaGenerated.csproj new file mode 100644 index 00000000..6ed1f276 --- /dev/null +++ b/src/generated/KiotaGenerated.csproj @@ -0,0 +1,20 @@ + + + + Source Generator project to assembly info as source such as package version. + netstandard2.0 + true + true + false + + + + + + + + + + + + diff --git a/src/generated/KiotaVersionGenerator.cs b/src/generated/KiotaVersionGenerator.cs new file mode 100644 index 00000000..2205af08 --- /dev/null +++ b/src/generated/KiotaVersionGenerator.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml; +using Microsoft.CodeAnalysis; + +namespace KiotaGenerated; + +[Generator] +public class KiotaVersionGenerator : ISourceGenerator +{ + public void Execute(GeneratorExecutionContext context) + { + var mainSyntaxTree = context.Compilation.SyntaxTrees + .First(static x => x.HasCompilationUnitRoot); + + var projectDirectory = Path.GetDirectoryName(mainSyntaxTree.FilePath); + + var version = "unknown"; + try { + XmlDocument csproj = new XmlDocument(); + projectDirectory = Path.Combine(projectDirectory, "..", "..", "..", "..", "Directory.Build.props"); + csproj.Load(projectDirectory); + version = csproj.GetElementsByTagName("VersionPrefix")[0].InnerText; + } catch (Exception e) + { + throw new FileNotFoundException($"KiotaVersionGenerator expanded in an invalid project, missing 'Directory.Build.props' file in the following directory {projectDirectory}", e); + } + + string source = $@"// +namespace Microsoft.Kiota.Http.Generated +{{ + /// + /// The version class + /// + public static class Version + {{ + /// + /// The current version string + /// + public static string Current() + {{ + return ""{version}""; + }} + }} +}} +"; + + // Add the source code to the compilation + context.AddSource($"KiotaVersion.g.cs", source); + } + + public void Initialize(GeneratorInitializationContext context) + { + // No initialization required for this one + } +} diff --git a/src/http/httpClient/Changelog-old.md b/src/http/httpClient/Changelog-old.md new file mode 100644 index 00000000..15c32ce7 --- /dev/null +++ b/src/http/httpClient/Changelog-old.md @@ -0,0 +1,287 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.4.3] - 2024-05-24 + +### Changed + +- Remove all LINQ usage from product code + +## [1.4.2] - 2024-05-21 + +### Added + +- Added an optional parameter to kiota middleware factory so options can be configured directly. [#233](https://github.com/microsoft/kiota-http-dotnet/issues/233) +- `GetDefaultHandlerTypes` added to `KiotaClientFactory` if you're creating your own `HttpClient` and still want to use the default handlers. + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [#256](https://github.com/microsoft/kiota-http-dotnet/issues/256) + +## [1.4.1] - 2024-05-07 + +## Changed + +- Use `SocketsHttpHandler` with `EnableMultipleHttp2Connections` as default HTTP message handler. + +## [1.4.0] + +## Added + +- KiotaClientFactory `create()` overload that accepts a list of handlers. + +## [1.3.12] - 2024-04-22 + +- UriReplacementHandler improvements to be added to middleware pipeline by default and respects options set in the HttpRequestMessage [#242](https://github.com/microsoft/kiota-http-dotnet/issues/242) +- Adds `ConfigureAwait(false)` calls to async calls [#240](https://github.com/microsoft/kiota-http-dotnet/issues/240). + +## [1.3.11] - 2024-04-19 + +## Changed + +- Fixes default handler for NET framework to unlock HTTP/2 scenarios [#237](https://github.com/microsoft/kiota-http-dotnet/issues/237) + +## [1.3.10] - 2024-04-19 + +## Changed + +- Have made System.* dependencies only be included on Net Standard's TFM & net 5 [#230](https://github.com/microsoft/kiota-http-dotnet/issues/230) + +## [1.3.9] - 2024-04-17 + +## Changed + +- Set default request version to be Http/2 + +## [1.3.8] - 2024-03-25] + +## Changed + +- When too many retries are attempted, the RetryHandler will now throw an `AggregateException` (instead of an `InvalidOperationException`). + The `InnerExceptions` property of the `AggregateException` will contain a list of `ApiException` with the HTTP status code and an error message if available. + +## [1.3.7] - 2024-02-26 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.3.6] - 2023-02-05 + +- Fixes `IsTrimmable` property on the project. + +## [1.3.5] - 2023-01-23 + +### Added + +- Adds support for `XXX` status code error mapping to HttpClientRequestAdapter. + +## [1.3.4] - 2023-12-29 + +### Added + +- Fixes `ActicitySource` memory leak when the HttpClientRequestAdapter does not construct the HttpClient internally. + +## [1.3.3] - 2023-11-28 + +### Added + +- Fixes a bug with internal `CloneAsync` method when using stream content types. + +## [1.3.2] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.3.1] - 2023-11-10 + +### Added + +- Fixes multiple initialization of `ActivitySource` instances on each request send [#161](https://github.com/microsoft/kiota-http-dotnet/issues/161). + +## [1.3.0] - 2023-11-02 + +### Added + +- Added uri replacement handler. + +## [1.2.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +## [1.1.1] - 2023-08-28 + +- Fixes a bug where the `ParametersNameDecodingHandler` would also decode query parameter values. + +## [1.1.0] - 2023-08-11 + +### Added + +- Added headers inspection handler to allow clients to observe request and response headers. + +## [1.0.6] - 2023-07-06 + +- Fixes a bug where empty streams would be passed to the serializers if the response content header is set. + +## [1.0.5] - 2023-06-29 + +- Fixes regression in request building when the passed httpClient base address ends with a `\` + +## [1.0.4] - 2023-06-15 + +- Fixes a bug where NullReference Exception is thrown if a requestInformation is sent without providing UriTemplate +- RequestAdapter passes `HttpCompletionOption.ResponseHeadersRead` to HttpClient for Stream responses to avoid memory consumption for large payloads. + +## [1.0.3] - 2023-06-09 + +- Added propagating the HttpClientRequestAdapter's supplied HttpClient BaseAddress as the adapter's initial BaseUrl + +### Added + +## [1.0.2] - 2023-04-06 + +### Changed + +- Includes Response headers in APIException for failed requests. + +## [1.0.1] - 2023-03-10 + +### Changed + +- Update minimum version of [`System.Diagnostics.DiagnosticSource`](https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource) to `6.0.0`. +- Update minimum version of [`System.Text.Json`](https://www.nuget.org/packages/System.Text.Json) to `6.0.0`. + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +## [1.0.0-rc.6] - 2023-02-03 + +### Added + +- Added the HTTP response status code on API exception. + +## [1.0.0-rc.5] - 2023-01-23 + +### Changed + +- Aligns the HttpClientRequestAdapter with other langugages to use the BaseUrl from the RequestAdapter as the baseUrl for making requests. + +## [1.0.0-rc.4] - 2023-01-09 + +### Added + +- Adds support for nullalbe reference types. + +## [1.0.0-rc.3] - 2023-01-09 + +### Added + +- Added a method to convert abstract requests to native requests in the request adapter interface. + +## [1.0.0-rc.2] - 2023-01-05 + +### Added + +- Adds this library version as a product in the user-agent + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +### Changed + +## [1.0.0-preview.13] - 2022-12-14 + +### Changed + +- Added multi-value headers support. + +## [1.0.0-preview.12] - 2022-12-01 + +### Changed + +- Fixes RetryHandler to return the real wait time + +## [1.0.0-preview.11] - 2022-10-17 + +### Changed + +- Changes the ResponseHandler parameter in IRequestAdapter to be a RequestOption + +## [1.0.0-preview.10] - 2022-09-19 + +### Added + +- Added tracing support through OpenTelemetry. + +## [1.0.0-preview.9] - 2022-09-07 + +### Added + +- Added support for additional status codes. + +## [1.0.0-preview.8] - 2022-05-19 + +### Changed + +- Fixed a bug where CAE support would keep connections open when retrying. + +## [1.0.0-preview.7] - 2022-05-13 + +### Added + +- Added support for continuous access evaluation. + +## [1.0.0-preview.6] - 2022-04-12 + +### Changed + +- Breaking: Changes target runtime to netstandard2.0 + +## [1.0.0-preview.5] - 2022-04-07 + +### Added + +- Added supports for decoding parameter names. + +## [1.0.0-preview.4] - 2022-04-06 + +### Changed + +- Fix issue with `HttpRequestAdapter` returning disposed streams when the requested return type is a Stream [#10](https://github.com/microsoft/kiota-http-dotnet/issues/10) + +## [1.0.0-preview.3] - 2022-03-28 + +### Added + +- Added support for 204 no content responses + +### Changed + +- Fixed a bug where BaseUrl would not be set in some scenarios + +## [1.0.0-preview.2] - 2022-03-18 + +### Changed + +- Fixed a bug where scalar request would not deserialize correctly. + +## [1.0.0-preview.1] - 2022-03-18 + +### Added + +- Initial Nuget release diff --git a/src/http/httpClient/Extensions/HttpRequestMessageExtensions.cs b/src/http/httpClient/Extensions/HttpRequestMessageExtensions.cs new file mode 100644 index 00000000..be29f135 --- /dev/null +++ b/src/http/httpClient/Extensions/HttpRequestMessageExtensions.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Extensions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Extensions +{ + /// + /// Contains extension methods for + /// + public static class HttpRequestMessageExtensions + { + /// + /// Gets a from + /// + /// + /// The representation of the request. + /// A request option + public static T? GetRequestOption(this HttpRequestMessage httpRequestMessage) where T : IRequestOption + { +#if NET5_0_OR_GREATER + if(httpRequestMessage.Options.TryGetValue(new HttpRequestOptionsKey(typeof(T).FullName!), out var requestOption)) +#else + if(httpRequestMessage.Properties.TryGetValue(typeof(T).FullName!, out var requestOption)) +#endif + { + return (T)requestOption!; + } + return default; + } + + /// + /// Create a new HTTP request by copying previous HTTP request's headers and properties from response's request message. + /// + /// The previous needs to be copy. + /// The for the request. + /// The . + /// + /// Re-issue a new HTTP request with the previous request's headers and properties + /// + internal static async Task CloneAsync(this HttpRequestMessage originalRequest, CancellationToken cancellationToken = default) + { + var newRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); + + // Copy request headers. + foreach(var header in originalRequest.Headers) + newRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + + // Copy request properties. +#if NET5_0_OR_GREATER + foreach(var property in originalRequest.Options) + if(property.Value is IRequestOption requestOption) + newRequest.Options.Set(new HttpRequestOptionsKey(property.Key), requestOption); + else + newRequest.Options.Set(new HttpRequestOptionsKey(property.Key), property.Value); +#else + foreach(var property in originalRequest.Properties) + IDictionaryExtensions.TryAdd(newRequest.Properties, property.Key, property.Value); +#endif + + // Set Content if previous request had one. + if(originalRequest.Content != null) + { + // HttpClient doesn't rewind streams and we have to explicitly do so. + var contentStream = new MemoryStream(); +#if NET5_0_OR_GREATER + await originalRequest.Content.CopyToAsync(contentStream, cancellationToken).ConfigureAwait(false); +#else + await originalRequest.Content.CopyToAsync(contentStream).ConfigureAwait(false); +#endif + + if(contentStream.CanSeek) + contentStream.Seek(0, SeekOrigin.Begin); + + newRequest.Content = new StreamContent(contentStream); + + // Copy content headers. + foreach(var header in originalRequest.Content.Headers) + { + newRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return newRequest; + } + + /// + /// Checks the HTTP request's content to determine if it's buffered or streamed content. + /// + /// The needs to be sent. + /// + internal static bool IsBuffered(this HttpRequestMessage httpRequestMessage) + { + HttpContent? requestContent = httpRequestMessage.Content; + + if((httpRequestMessage.Method == HttpMethod.Put || httpRequestMessage.Method == HttpMethod.Post || httpRequestMessage.Method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase)) + && requestContent != null && (requestContent.Headers.ContentLength == null || (int)requestContent.Headers.ContentLength == -1)) + { + return false; + } + return true; + } + } +} diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs new file mode 100644 index 00000000..33bd2e91 --- /dev/null +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -0,0 +1,666 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using Microsoft.Kiota.Abstractions.Authentication; +using System.Threading; +using System.Net; +using Microsoft.Kiota.Abstractions.Extensions; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +using System.Diagnostics; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Kiota.Http.HttpClientLibrary +{ + /// + /// The implementation for sending requests. + /// + public class HttpClientRequestAdapter : IRequestAdapter, IDisposable + { + private readonly HttpClient client; + private readonly IAuthenticationProvider authProvider; + private IParseNodeFactory pNodeFactory; + private ISerializationWriterFactory sWriterFactory; + private string? baseUrl; + private readonly bool createdClient; + private readonly ObservabilityOptions obsOptions; + private readonly ActivitySource activitySource; + /// + /// Initializes a new instance of the class. + /// The authentication provider. + /// The parse node factory. + /// The serialization writer factory. + /// The native HTTP client. + /// The observability options. + /// + public HttpClientRequestAdapter(IAuthenticationProvider authenticationProvider, IParseNodeFactory? parseNodeFactory = null, ISerializationWriterFactory? serializationWriterFactory = null, HttpClient? httpClient = null, ObservabilityOptions? observabilityOptions = null) + { + authProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); + createdClient = httpClient == null; + client = httpClient ?? KiotaClientFactory.Create(); + BaseUrl = client.BaseAddress?.ToString(); + pNodeFactory = parseNodeFactory ?? ParseNodeFactoryRegistry.DefaultInstance; + sWriterFactory = serializationWriterFactory ?? SerializationWriterFactoryRegistry.DefaultInstance; + obsOptions = observabilityOptions ?? new ObservabilityOptions(); + activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + } + /// Factory to use to get a serializer for payload serialization + public ISerializationWriterFactory SerializationWriterFactory + { + get + { + return sWriterFactory; + } + } + /// + /// The base url for every request. + /// + public string? BaseUrl + { + get => baseUrl; + set => this.baseUrl = value?.TrimEnd('/'); + } + private static readonly char[] charactersToDecodeForUriTemplate = ['$', '.', '-', '~']; + private static readonly Regex queryParametersCleanupRegex = new(@"\{\?[^\}]+}", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Singleline, TimeSpan.FromMilliseconds(100)); + private Activity? startTracingSpan(RequestInformation requestInfo, string methodName) + { + var decodedUriTemplate = ParametersNameDecodingHandler.DecodeUriEncodedString(requestInfo.UrlTemplate, charactersToDecodeForUriTemplate); + var telemetryPathValue = string.IsNullOrEmpty(decodedUriTemplate) ? string.Empty : queryParametersCleanupRegex.Replace(decodedUriTemplate, string.Empty); + var span = activitySource?.StartActivity($"{methodName} - {telemetryPathValue}"); + span?.SetTag("http.uri_template", decodedUriTemplate); + return span; + } + /// + /// Send a instance with a collection instance of + /// + /// The instance to send + /// The factory of the response model to deserialize the response into. + /// The error factories mapping to use in case of a failed request. + /// The to use for cancelling the request. + public async Task?> SendCollectionAsync(RequestInformation requestInfo, ParsableFactory factory, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) where ModelType : IParsable + { + using var span = startTracingSpan(requestInfo, nameof(SendCollectionAsync)); + var response = await GetHttpResponseMessage(requestInfo, cancellationToken, span).ConfigureAwait(false); + requestInfo.Content?.Dispose(); + var responseHandler = GetResponseHandler(requestInfo); + if(responseHandler == null) + { + try + { + await ThrowIfFailedResponse(response, errorMapping, span, cancellationToken).ConfigureAwait(false); + if(shouldReturnNull(response)) return default; + var rootNode = await GetRootParseNode(response, cancellationToken).ConfigureAwait(false); + using var spanForDeserialization = activitySource?.StartActivity(nameof(IParseNode.GetCollectionOfObjectValues)); + var result = rootNode?.GetCollectionOfObjectValues(factory); + SetResponseType(result, span); + return result; + } + finally + { + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + } + } + else + { + span?.AddEvent(new ActivityEvent(EventResponseHandlerInvokedKey)); + return await responseHandler.HandleResponseAsync>(response, errorMapping).ConfigureAwait(false); + } + } + /// + /// Executes the HTTP request specified by the given RequestInformation and returns the deserialized primitive response model collection. + /// + /// The RequestInformation object to use for the HTTP request. + /// The error factories mapping to use in case of a failed request. + /// The to use for cancelling the request. + /// The deserialized primitive response model collection. +#if NET5_0_OR_GREATER + public async Task?> SendPrimitiveCollectionAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] ModelType>(RequestInformation requestInfo, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) +#else + public async Task?> SendPrimitiveCollectionAsync(RequestInformation requestInfo, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) +#endif + { + using var span = startTracingSpan(requestInfo, nameof(SendPrimitiveCollectionAsync)); + var response = await GetHttpResponseMessage(requestInfo, cancellationToken, span).ConfigureAwait(false); + requestInfo.Content?.Dispose(); + var responseHandler = GetResponseHandler(requestInfo); + if(responseHandler == null) + { + try + { + await ThrowIfFailedResponse(response, errorMapping, span, cancellationToken).ConfigureAwait(false); + if(shouldReturnNull(response)) return default; + var rootNode = await GetRootParseNode(response, cancellationToken).ConfigureAwait(false); + using var spanForDeserialization = activitySource?.StartActivity(nameof(IParseNode.GetCollectionOfPrimitiveValues)); + var result = rootNode?.GetCollectionOfPrimitiveValues(); + SetResponseType(result, span); + return result; + } + finally + { + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + } + } + else + { + span?.AddEvent(new ActivityEvent(EventResponseHandlerInvokedKey)); + return await responseHandler.HandleResponseAsync>(response, errorMapping).ConfigureAwait(false); + } + } + /// + /// The key for the tracing event raised when a response handler is called. + /// + public const string EventResponseHandlerInvokedKey = "com.microsoft.kiota.response_handler_invoked"; + /// + /// Send a instance with an instance of + /// + /// The instance to send + /// The factory of the response model to deserialize the response into. + /// The error factories mapping to use in case of a failed request. + /// The to use for cancelling the request. + /// The deserialized response model. + public async Task SendAsync(RequestInformation requestInfo, ParsableFactory factory, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) where ModelType : IParsable + { + using var span = startTracingSpan(requestInfo, nameof(SendAsync)); + var response = await GetHttpResponseMessage(requestInfo, cancellationToken, span).ConfigureAwait(false); + requestInfo.Content?.Dispose(); + var responseHandler = GetResponseHandler(requestInfo); + if(responseHandler == null) + { + try + { + await ThrowIfFailedResponse(response, errorMapping, span, cancellationToken).ConfigureAwait(false); + if(shouldReturnNull(response)) return default; + var rootNode = await GetRootParseNode(response, cancellationToken).ConfigureAwait(false); + if(rootNode == null) return default; + using var spanForDeserialization = activitySource?.StartActivity(nameof(IParseNode.GetObjectValue)); + var result = rootNode.GetObjectValue(factory); + SetResponseType(result, span); + return result; + } + finally + { + if(typeof(ModelType) != typeof(Stream)) + { + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + } + } + } + else + { + span?.AddEvent(new ActivityEvent(EventResponseHandlerInvokedKey)); + return await responseHandler.HandleResponseAsync(response, errorMapping).ConfigureAwait(false); + } + } + /// + /// Send a instance with a primitive instance of + /// + /// The instance to send + /// The error factories mapping to use in case of a failed request. + /// The to use for cancelling the request. + /// The deserialized primitive response model. +#if NET5_0_OR_GREATER + public async Task SendPrimitiveAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] ModelType>(RequestInformation requestInfo, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) +#else + public async Task SendPrimitiveAsync(RequestInformation requestInfo, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) +#endif + { + using var span = startTracingSpan(requestInfo, nameof(SendPrimitiveAsync)); + var modelType = typeof(ModelType); + var isStreamResponse = modelType == typeof(Stream); + var response = await GetHttpResponseMessage(requestInfo, cancellationToken, span, isStreamResponse: isStreamResponse).ConfigureAwait(false); + requestInfo.Content?.Dispose(); + var responseHandler = GetResponseHandler(requestInfo); + if(responseHandler == null) + { + try + { + await ThrowIfFailedResponse(response, errorMapping, span, cancellationToken).ConfigureAwait(false); + if(shouldReturnNull(response)) return default; + if(isStreamResponse) + { +#if NET5_0_OR_GREATER + var result = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +#else + var result = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); +#endif + if(result.CanSeek && result.Length == 0) + { + result.Dispose(); + return default; + } + SetResponseType(result, span); + return (ModelType)(result as object); + } + else + { + var rootNode = await GetRootParseNode(response, cancellationToken).ConfigureAwait(false); + object? result; + using var spanForDeserialization = activitySource?.StartActivity($"Get{modelType.Name.TrimEnd('?')}Value"); + if(rootNode == null) + { + result = null; + } + else if(modelType == typeof(bool?)) + { + result = rootNode.GetBoolValue(); + } + else if(modelType == typeof(byte?)) + { + result = rootNode.GetByteValue(); + } + else if(modelType == typeof(sbyte?)) + { + result = rootNode.GetSbyteValue(); + } + else if(modelType == typeof(string)) + { + result = rootNode.GetStringValue(); + } + else if(modelType == typeof(int?)) + { + result = rootNode.GetIntValue(); + } + else if(modelType == typeof(float?)) + { + result = rootNode.GetFloatValue(); + } + else if(modelType == typeof(long?)) + { + result = rootNode.GetLongValue(); + } + else if(modelType == typeof(double?)) + { + result = rootNode.GetDoubleValue(); + } + else if(modelType == typeof(decimal?)) + { + result = rootNode.GetDecimalValue(); + } + else if(modelType == typeof(Guid?)) + { + result = rootNode.GetGuidValue(); + } + else if(modelType == typeof(DateTimeOffset?)) + { + result = rootNode.GetDateTimeOffsetValue(); + } + else if(modelType == typeof(TimeSpan?)) + { + result = rootNode.GetTimeSpanValue(); + } + else if(modelType == typeof(Date?)) + { + result = rootNode.GetDateValue(); + } + else throw new InvalidOperationException("error handling the response, unexpected type"); + SetResponseType(result, span); + return (ModelType)result!; + } + } + finally + { + if(typeof(ModelType) != typeof(Stream)) + { + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + } + } + } + else + { + span?.AddEvent(new ActivityEvent(EventResponseHandlerInvokedKey)); + return await responseHandler.HandleResponseAsync(response, errorMapping).ConfigureAwait(false); + } + } + /// + /// Send a instance with an empty request body + /// + /// The instance to send + /// The error factories mapping to use in case of a failed request. + /// The to use for cancelling the request. + /// + public async Task SendNoContentAsync(RequestInformation requestInfo, Dictionary>? errorMapping = default, CancellationToken cancellationToken = default) + { + using var span = startTracingSpan(requestInfo, nameof(SendNoContentAsync)); + var response = await GetHttpResponseMessage(requestInfo, cancellationToken, span).ConfigureAwait(false); + requestInfo.Content?.Dispose(); + var responseHandler = GetResponseHandler(requestInfo); + if(responseHandler == null) + { + try + { + await ThrowIfFailedResponse(response, errorMapping, span, cancellationToken).ConfigureAwait(false); + } + finally + { + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + } + } + else + { + span?.AddEvent(new ActivityEvent(EventResponseHandlerInvokedKey)); + await responseHandler.HandleResponseAsync(response, errorMapping).ConfigureAwait(false); + } + } + private static void SetResponseType(object? result, Activity? activity) + { + if(result != null) + { + activity?.SetTag("com.microsoft.kiota.response.type", result.GetType().FullName); + } + } + private static async Task DrainAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if(response.Content != null) + { +#if NET5_0_OR_GREATER + using var discard = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +#else + using var discard = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); +#endif + response.Content.Dispose(); + } + response.Dispose(); + } + private static bool shouldReturnNull(HttpResponseMessage response) + { + return response.StatusCode == HttpStatusCode.NoContent + || response.Content == null + || response.Content.GetType().Name.Equals("EmptyContent", StringComparison.OrdinalIgnoreCase);// In NET 5 and above, Content is never null but represented by the internal class EmptyContent + // which MAY return instances of EmptyReadStream on reading(which is not seekable thus we can't read/get the length) + // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/EmptyReadStream.cs + } + /// + /// The attribute name used to indicate whether an error code mapping was found. + /// + public const string ErrorMappingFoundAttributeName = "com.microsoft.kiota.error.mapping_found"; + /// + /// The attribute name used to indicate whether the error response contained a body. + /// + public const string ErrorBodyFoundAttributeName = "com.microsoft.kiota.error.body_found"; + private async Task ThrowIfFailedResponse(HttpResponseMessage response, Dictionary>? errorMapping, Activity? activityForAttributes, CancellationToken cancellationToken) + { + using var span = activitySource?.StartActivity(nameof(ThrowIfFailedResponse)); + if(response.IsSuccessStatusCode) return; + + activityForAttributes?.SetStatus(ActivityStatusCode.Error, "received_error_response"); + + var statusCodeAsInt = (int)response.StatusCode; + var statusCodeAsString = statusCodeAsInt.ToString(); + var responseHeadersDictionary = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var header in response.Headers) + responseHeadersDictionary[header.Key] = header.Value; + ParsableFactory? errorFactory; + if(errorMapping == null || + !errorMapping.TryGetValue(statusCodeAsString, out errorFactory) && + !(statusCodeAsInt >= 400 && statusCodeAsInt < 500 && errorMapping.TryGetValue("4XX", out errorFactory)) && + !(statusCodeAsInt >= 500 && statusCodeAsInt < 600 && errorMapping.TryGetValue("5XX", out errorFactory)) && + !errorMapping.TryGetValue("XXX", out errorFactory)) + { + activityForAttributes?.SetTag(ErrorMappingFoundAttributeName, false); + throw new ApiException($"The server returned an unexpected status code and no error factory is registered for this code: {statusCodeAsString}") + { + ResponseStatusCode = statusCodeAsInt, + ResponseHeaders = responseHeadersDictionary + }; + } + activityForAttributes?.SetTag(ErrorMappingFoundAttributeName, true); + + var rootNode = await GetRootParseNode(response, cancellationToken).ConfigureAwait(false); + activityForAttributes?.SetTag(ErrorBodyFoundAttributeName, rootNode != null); + var spanForDeserialization = activitySource?.StartActivity(nameof(IParseNode.GetObjectValue)); + var result = rootNode?.GetObjectValue(errorFactory); + SetResponseType(result, activityForAttributes); + spanForDeserialization?.Dispose(); + if(result is not Exception ex) + throw new ApiException($"The server returned an unexpected status code and the error registered for this code failed to deserialize: {statusCodeAsString}") + { + ResponseStatusCode = statusCodeAsInt, + ResponseHeaders = responseHeadersDictionary + }; + if(result is ApiException apiEx) + { + apiEx.ResponseStatusCode = statusCodeAsInt; + apiEx.ResponseHeaders = responseHeadersDictionary; + } + + throw ex; + } + private static IResponseHandler? GetResponseHandler(RequestInformation requestInfo) + { + return requestInfo.GetRequestOption()?.ResponseHandler; + } + private async Task GetRootParseNode(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var span = activitySource?.StartActivity(nameof(GetRootParseNode)); + var responseContentType = response.Content?.Headers?.ContentType?.MediaType?.ToLowerInvariant(); + if(string.IsNullOrEmpty(responseContentType)) + return null; +#if NET5_0_OR_GREATER + using var contentStream = await (response.Content?.ReadAsStreamAsync(cancellationToken) ?? Task.FromResult(Stream.Null)).ConfigureAwait(false); +#else + using var contentStream = await (response.Content?.ReadAsStreamAsync() ?? Task.FromResult(Stream.Null)).ConfigureAwait(false); +#endif + if(contentStream == Stream.Null || (contentStream.CanSeek && contentStream.Length == 0)) + return null;// ensure a useful stream is passed to the factory +#pragma warning disable CS0618 // Type or member is obsolete + //TODO remove with v2 + var rootNode = pNodeFactory is IAsyncParseNodeFactory asyncParseNodeFactory ? await asyncParseNodeFactory.GetRootParseNodeAsync(responseContentType!, contentStream, cancellationToken).ConfigureAwait(false) : pNodeFactory.GetRootParseNode(responseContentType!, contentStream); +#pragma warning restore CS0618 // Type or member is obsolete + return rootNode; + } + private const string ClaimsKey = "claims"; + private const string BearerAuthenticationScheme = "Bearer"; + private static Func filterAuthHeader = static x => x.Scheme.Equals(BearerAuthenticationScheme, StringComparison.OrdinalIgnoreCase); + private async Task GetHttpResponseMessage(RequestInformation requestInfo, CancellationToken cancellationToken, Activity? activityForAttributes, string? claims = default, bool isStreamResponse = false) + { + using var span = activitySource?.StartActivity(nameof(GetHttpResponseMessage)); + if(requestInfo == null) + throw new ArgumentNullException(nameof(requestInfo)); + + SetBaseUrlForRequestInformation(requestInfo); + + var additionalAuthenticationContext = string.IsNullOrEmpty(claims) ? null : new Dictionary { { ClaimsKey, claims! } }; + await authProvider.AuthenticateRequestAsync(requestInfo, additionalAuthenticationContext, cancellationToken).ConfigureAwait(false); + + using var message = GetRequestMessageFromRequestInformation(requestInfo, activityForAttributes); + var response = isStreamResponse ? await this.client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false) : + await this.client.SendAsync(message, cancellationToken).ConfigureAwait(false); + if(response == null) + { + var ex = new InvalidOperationException("Could not get a response after calling the service"); + throw ex; + } + if(response.Headers.TryGetValues("Content-Length", out var contentLengthValues)) + { + using var contentLengthEnumerator = contentLengthValues.GetEnumerator(); + if(contentLengthEnumerator.MoveNext() && int.TryParse(contentLengthEnumerator.Current, out var contentLength)) + { + activityForAttributes?.SetTag("http.response_content_length", contentLength); + } + } + if(response.Headers.TryGetValues("Content-Type", out var contentTypeValues)) + { + using var contentTypeEnumerator = contentTypeValues.GetEnumerator(); + if(contentTypeEnumerator.MoveNext()) + { + activityForAttributes?.SetTag("http.response_content_type", contentTypeEnumerator.Current); + } + } + activityForAttributes?.SetTag("http.status_code", (int)response.StatusCode); + activityForAttributes?.SetTag("http.flavor", $"{response.Version.Major}.{response.Version.Minor}"); + + return await RetryCAEResponseIfRequired(response, requestInfo, cancellationToken, claims, activityForAttributes).ConfigureAwait(false); + } + + private static readonly Regex caeValueRegex = new("\"([^\"]*)\"", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + + /// + /// The key for the event raised by tracing when an authentication challenge is received + /// + public const string AuthenticateChallengedEventKey = "com.microsoft.kiota.authenticate_challenge_received"; + + private async Task RetryCAEResponseIfRequired(HttpResponseMessage response, RequestInformation requestInfo, CancellationToken cancellationToken, string? claims, Activity? activityForAttributes) + { + using var span = activitySource?.StartActivity(nameof(RetryCAEResponseIfRequired)); + if(response.StatusCode == HttpStatusCode.Unauthorized && + string.IsNullOrEmpty(claims) && // avoid infinite loop, we only retry once + (requestInfo.Content?.CanSeek ?? true)) + { + AuthenticationHeaderValue? authHeader = null; + foreach(var header in response.Headers.WwwAuthenticate) + { + if(filterAuthHeader(header)) + { + authHeader = header; + break; + } + } + + if(authHeader is not null) + { + var authHeaderParameters = authHeader.Parameter?.Split(new[]{','}, StringSplitOptions.RemoveEmptyEntries); + + string? rawResponseClaims = null; + if(authHeaderParameters != null) + { + foreach(var parameter in authHeaderParameters) + { + var trimmedParameter = parameter.Trim(); + if(trimmedParameter.StartsWith(ClaimsKey, StringComparison.OrdinalIgnoreCase)) + { + rawResponseClaims = trimmedParameter; + break; + } + } + } + + if(rawResponseClaims != null && + caeValueRegex.Match(rawResponseClaims) is Match claimsMatch && + claimsMatch.Groups.Count > 1 && + claimsMatch.Groups[1].Value is string responseClaims) + { + span?.AddEvent(new ActivityEvent(AuthenticateChallengedEventKey)); + activityForAttributes?.SetTag("http.retry_count", 1); + requestInfo.Content?.Seek(0, SeekOrigin.Begin); + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + return await GetHttpResponseMessage(requestInfo, cancellationToken, activityForAttributes, responseClaims).ConfigureAwait(false); + } + } + } + return response; + } + + private void SetBaseUrlForRequestInformation(RequestInformation requestInfo) + { + IDictionaryExtensions.AddOrReplace(requestInfo.PathParameters, "baseurl", BaseUrl!); + } + /// + public async Task ConvertToNativeRequestAsync(RequestInformation requestInfo, CancellationToken cancellationToken = default) + { + await authProvider.AuthenticateRequestAsync(requestInfo, null, cancellationToken).ConfigureAwait(false); + if(GetRequestMessageFromRequestInformation(requestInfo, null) is T result) + return result; + else throw new InvalidOperationException($"Could not convert the request information to a {typeof(T).Name}"); + } + + private HttpRequestMessage GetRequestMessageFromRequestInformation(RequestInformation requestInfo, Activity? activityForAttributes) + { + using var span = activitySource?.StartActivity(nameof(GetRequestMessageFromRequestInformation)); + SetBaseUrlForRequestInformation(requestInfo);// this method can also be called from a different context so ensure the baseUrl is added. + activityForAttributes?.SetTag("http.method", requestInfo.HttpMethod.ToString()); + var requestUri = requestInfo.URI; + activityForAttributes?.SetTag("http.host", requestUri.Host); + activityForAttributes?.SetTag("http.scheme", requestUri.Scheme); + if(obsOptions.IncludeEUIIAttributes) + activityForAttributes?.SetTag("http.uri", requestUri.ToString()); + var message = new HttpRequestMessage + { + Method = new HttpMethod(requestInfo.HttpMethod.ToString().ToUpperInvariant()), + RequestUri = requestUri, + Version = new Version(2, 0) + }; + + if(requestInfo.RequestOptions != null) +#if NET5_0_OR_GREATER + { + foreach (var option in requestInfo.RequestOptions) + message.Options.Set(new HttpRequestOptionsKey(option.GetType().FullName!), option); + } + message.Options.Set(new HttpRequestOptionsKey(typeof(ObservabilityOptions).FullName!), obsOptions); +#else + { + foreach(var option in requestInfo.RequestOptions) + IDictionaryExtensions.TryAdd(message.Properties, option.GetType().FullName!, option); + } + IDictionaryExtensions.TryAdd(message.Properties!, typeof(ObservabilityOptions).FullName, obsOptions); +#endif + + if(requestInfo.Content != null && requestInfo.Content != Stream.Null) + message.Content = new StreamContent(requestInfo.Content); + if(requestInfo.Headers != null) + foreach(var header in requestInfo.Headers) + if(!message.Headers.TryAddWithoutValidation(header.Key, header.Value) && message.Content != null) + message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);// Try to add the headers we couldn't add to the HttpRequestMessage before to the HttpContent + + if(message.Content != null) + { + if(message.Content.Headers.TryGetValues("Content-Length", out var contentLenValues)) + { + var contentLenEnumerator = contentLenValues.GetEnumerator(); + if(contentLenEnumerator.MoveNext() && int.TryParse(contentLenEnumerator.Current, out var contentLenValueInt)) + activityForAttributes?.SetTag("http.request_content_length", contentLenValueInt); + } + if(message.Content.Headers.TryGetValues("Content-Type", out var contentTypeValues)) + { + var contentTypeEnumerator = contentTypeValues.GetEnumerator(); + if(contentTypeEnumerator.MoveNext()) + activityForAttributes?.SetTag("http.request_content_type", contentTypeEnumerator.Current); + } + } + return message; + } + + /// + /// Enable the backing store with the provided + /// + /// The to use + public void EnableBackingStore(IBackingStoreFactory backingStoreFactory) + { + pNodeFactory = ApiClientBuilder.EnableBackingStoreForParseNodeFactory(pNodeFactory) ?? throw new InvalidOperationException("Could not enable backing store for the parse node factory"); + sWriterFactory = ApiClientBuilder.EnableBackingStoreForSerializationWriterFactory(sWriterFactory) ?? throw new InvalidOperationException("Could not enable backing store for the serializer writer factory"); + if(backingStoreFactory != null) + BackingStoreFactorySingleton.Instance = backingStoreFactory; + } + + /// + /// Dispose/cleanup the client + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose/cleanup the client + /// + protected virtual void Dispose(bool disposing) + { + // Cleanup + if(createdClient) + { + client?.Dispose(); + } + } + } +} diff --git a/src/http/httpClient/KiotaClientFactory.cs b/src/http/httpClient/KiotaClientFactory.cs new file mode 100644 index 00000000..8a6957e1 --- /dev/null +++ b/src/http/httpClient/KiotaClientFactory.cs @@ -0,0 +1,176 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary +{ + /// + /// This class is used to build the HttpClient instance used by the core service. + /// + public static class KiotaClientFactory + { + /// + /// Initializes the with the default configuration and middlewares including a authentication middleware using the if provided. + /// + /// The final in the http pipeline. Can be configured for proxies, auto-decompression and auto-redirects + /// A array of objects passed to the default handlers. + /// The with the default middlewares. + public static HttpClient Create(HttpMessageHandler? finalHandler = null, IRequestOption[]? optionsForHandlers = null) + { + var defaultHandlersEnumerable = CreateDefaultHandlers(optionsForHandlers); + int count = 0; + foreach(var _ in defaultHandlersEnumerable) count++; + + var defaultHandlersArray = new DelegatingHandler[count]; + int index = 0; + foreach(var handler2 in defaultHandlersEnumerable) + { + defaultHandlersArray[index++] = handler2; + } + var handler = ChainHandlersCollectionAndGetFirstLink(finalHandler ?? GetDefaultHttpMessageHandler(), defaultHandlersArray); + return handler != null ? new HttpClient(handler) : new HttpClient(); + } + + /// + /// Initializes the with a custom middleware pipeline. + /// + /// The instances to create the from. + /// The final in the http pipeline. Can be configured for proxies, auto-decompression and auto-redirects + /// The with the custom handlers. + public static HttpClient Create(IList handlers, HttpMessageHandler? finalHandler = null) + { + if(handlers == null || handlers.Count == 0) + return Create(finalHandler); + + DelegatingHandler[] handlersArray = new DelegatingHandler[handlers.Count]; + for(int i = 0; i < handlers.Count; i++) + { + handlersArray[i] = handlers[i]; + } + + var handler = ChainHandlersCollectionAndGetFirstLink(finalHandler ?? GetDefaultHttpMessageHandler(), handlersArray); + return handler != null ? new HttpClient(handler) : new HttpClient(); + } + + /// + /// Creates a default set of middleware to be used by the . + /// + /// A list of the default handlers used by the client. + public static IList CreateDefaultHandlers(IRequestOption[]? optionsForHandlers = null) + { + optionsForHandlers ??= Array.Empty(); + + UriReplacementHandlerOption? uriReplacementOption = null; + RetryHandlerOption? retryHandlerOption = null; + RedirectHandlerOption? redirectHandlerOption = null; + ParametersNameDecodingOption? parametersNameDecodingOption = null; + UserAgentHandlerOption? userAgentHandlerOption = null; + HeadersInspectionHandlerOption? headersInspectionHandlerOption = null; + + foreach(var option in optionsForHandlers) + { + if(uriReplacementOption == null && option is UriReplacementHandlerOption uriOption) + uriReplacementOption = uriOption; + else if(retryHandlerOption == null && option is RetryHandlerOption retryOption) + retryHandlerOption = retryOption; + else if(redirectHandlerOption == null && option is RedirectHandlerOption redirectOption) + redirectHandlerOption = redirectOption; + else if(parametersNameDecodingOption == null && option is ParametersNameDecodingOption parametersOption) + parametersNameDecodingOption = parametersOption; + else if(userAgentHandlerOption == null && option is UserAgentHandlerOption userAgentOption) + userAgentHandlerOption = userAgentOption; + else if(headersInspectionHandlerOption == null && option is HeadersInspectionHandlerOption headersOption) + headersInspectionHandlerOption = headersOption; + } + + return new List + { + uriReplacementOption != null ? new UriReplacementHandler(uriReplacementOption) : new UriReplacementHandler(), + retryHandlerOption != null ? new RetryHandler(retryHandlerOption) : new RetryHandler(), + redirectHandlerOption != null ? new RedirectHandler(redirectHandlerOption) : new RedirectHandler(), + parametersNameDecodingOption != null ? new ParametersNameDecodingHandler(parametersNameDecodingOption) : new ParametersNameDecodingHandler(), + userAgentHandlerOption != null ? new UserAgentHandler(userAgentHandlerOption) : new UserAgentHandler(), + headersInspectionHandlerOption != null ? new HeadersInspectionHandler(headersInspectionHandlerOption) : new HeadersInspectionHandler(), + }; + } + + /// + /// Gets the default handler types. + /// + /// A list of all the default handlers + /// Order matters + public static IList GetDefaultHandlerTypes() + { + return new List + { + typeof(UriReplacementHandler), + typeof(RetryHandler), + typeof(RedirectHandler), + typeof(ParametersNameDecodingHandler), + typeof(UserAgentHandler), + typeof(HeadersInspectionHandler), + }; + } + + /// + /// Creates a to use for the from the provided instances. Order matters. + /// + /// The final in the http pipeline. Can be configured for proxies, auto-decompression and auto-redirects + /// The instances to create the from. + /// The created . + public static DelegatingHandler? ChainHandlersCollectionAndGetFirstLink(HttpMessageHandler? finalHandler, params DelegatingHandler[] handlers) + { + if(handlers == null || handlers.Length == 0) return default; + var handlersCount = handlers.Length; + for(var i = 0; i < handlersCount; i++) + { + var handler = handlers[i]; + var previousItemIndex = i - 1; + if(previousItemIndex >= 0) + { + var previousHandler = handlers[previousItemIndex]; + previousHandler.InnerHandler = handler; + } + } + if(finalHandler != null) + handlers[handlers.Length - 1].InnerHandler = finalHandler; + return handlers[0];//first + } + /// + /// Creates a to use for the from the provided instances. Order matters. + /// + /// The instances to create the from. + /// The created . + public static DelegatingHandler? ChainHandlersCollectionAndGetFirstLink(params DelegatingHandler[] handlers) + { + return ChainHandlersCollectionAndGetFirstLink(null, handlers); + } + /// + /// Gets a default Http Client handler with the appropriate proxy configurations + /// + /// The proxy to be used with created client. + /// + public static HttpMessageHandler GetDefaultHttpMessageHandler(IWebProxy? proxy = null) + { +#if NETFRAMEWORK + // If custom proxy is passed, the WindowsProxyUsePolicy will need updating + // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http.WinHttpHandler/src/System/Net/Http/WinHttpHandler.cs#L575 + var proxyPolicy = proxy != null ? WindowsProxyUsePolicy.UseCustomProxy : WindowsProxyUsePolicy.UseWinHttpProxy; + return new WinHttpHandler { Proxy = proxy, AutomaticDecompression = DecompressionMethods.None, WindowsProxyUsePolicy = proxyPolicy, SendTimeout = System.Threading.Timeout.InfiniteTimeSpan, ReceiveDataTimeout = System.Threading.Timeout.InfiniteTimeSpan, ReceiveHeadersTimeout = System.Threading.Timeout.InfiniteTimeSpan, EnableMultipleHttp2Connections = true }; +#elif NET5_0_OR_GREATER + return new SocketsHttpHandler { Proxy = proxy, AllowAutoRedirect = false, EnableMultipleHttp2Connections = true }; +#else + return new HttpClientHandler { Proxy = proxy, AllowAutoRedirect = false }; +#endif + } + } +} diff --git a/src/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.csproj b/src/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.csproj new file mode 100644 index 00000000..21ff350b --- /dev/null +++ b/src/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.csproj @@ -0,0 +1,26 @@ + + + + + Kiota Http provider implementation for dotnet with HttpClient. + Kiota Http Library for dotnet + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0;net462 + true + + + + + + + + + + + + + + + + + diff --git a/src/http/httpClient/Middleware/ActivitySourceRegistry.cs b/src/http/httpClient/Middleware/ActivitySourceRegistry.cs new file mode 100644 index 00000000..0cde89c9 --- /dev/null +++ b/src/http/httpClient/Middleware/ActivitySourceRegistry.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware; + +/// +/// Internal static registry for activity sources during tracing. +/// +internal class ActivitySourceRegistry +{ + private readonly ConcurrentDictionary _activitySources = new (StringComparer.OrdinalIgnoreCase); + + /// + /// The default instance of the registry + /// + public static readonly ActivitySourceRegistry DefaultInstance = new(); + + /// + /// Get an a instance with the given name or create one. + /// + /// The name of the + /// + /// When the parameter is null or empty + public ActivitySource GetOrCreateActivitySource(string sourceName) + { + if(string.IsNullOrEmpty(sourceName)) + throw new ArgumentNullException(nameof(sourceName)); + + return _activitySources.GetOrAdd(sourceName, static source => new ActivitySource(source)); + + } +} diff --git a/src/http/httpClient/Middleware/ChaosHandler.cs b/src/http/httpClient/Middleware/ChaosHandler.cs new file mode 100644 index 00000000..7cf39c39 --- /dev/null +++ b/src/http/httpClient/Middleware/ChaosHandler.cs @@ -0,0 +1,304 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// A implementation that is used for simulating server failures. + /// + public class ChaosHandler : DelegatingHandler, IDisposable + { + private readonly Random _random; + private readonly ChaosHandlerOption _chaosHandlerOptions; + private List _knownFailures = new(); + private const string Json = "application/json"; + + /// + /// Create a ChaosHandler. + /// + /// Optional parameter to change default behavior of handler. + public ChaosHandler(ChaosHandlerOption? chaosHandlerOptions = null) + { + _chaosHandlerOptions = chaosHandlerOptions ?? new ChaosHandlerOption(); + _random = new Random(DateTime.Now.Millisecond); + LoadKnownFailures(_chaosHandlerOptions.KnownChaos); + } + /// + /// The key used for the open telemetry event. + /// + public const string ChaosHandlerTriggeredEventKey = "com.microsoft.kiota.chaos_handler_triggered"; + /// + /// Sends the request + /// + /// The request to send. + /// The for the request. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + // Select global or per request options + var chaosHandlerOptions = request.GetRequestOption() ?? _chaosHandlerOptions; + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(ChaosHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.chaos.enable", true); + } + else + { + activity = null; + } + try + { + + // Planned Chaos or Random? + if(chaosHandlerOptions.PlannedChaosFactory != null && chaosHandlerOptions.PlannedChaosFactory(request) is HttpResponseMessage plannedResponse) + { + plannedResponse.RequestMessage = request; + activity?.AddEvent(new(ChaosHandlerTriggeredEventKey)); + activity?.SetTag("com.microsoft.kiota.handler.chaos.planned", true); + return plannedResponse; + } + else if(_random.Next(100) < chaosHandlerOptions.ChaosPercentLevel) + { + var chaosResponse = CreateChaosResponse(chaosHandlerOptions.KnownChaos ?? _knownFailures!); + chaosResponse.RequestMessage = request; + activity?.AddEvent(new(ChaosHandlerTriggeredEventKey)); + activity?.SetTag("com.microsoft.kiota.handler.chaos.planned", false); + return chaosResponse; + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + activity?.Dispose(); + } + } + + private HttpResponseMessage CreateChaosResponse(List knownFailures) + { + var responseIndex = _random.Next(knownFailures.Count); + return knownFailures[responseIndex]; + } + + private void LoadKnownFailures(List? knownFailures) + { + if(knownFailures?.Count > 0) + { + _knownFailures = knownFailures; + } + else + { + _knownFailures = new List + { + Create429TooManyRequestsResponse(new TimeSpan(0, 0, 3)), + Create503Response(new TimeSpan(0, 0, 3)), + Create504GatewayTimeoutResponse(new TimeSpan(0, 0, 3)) + }; + } + } + + /// + /// Create a HTTP status 429 response message + /// + /// for retry condition header value + /// A object simulating a 429 response + public static HttpResponseMessage Create429TooManyRequestsResponse(TimeSpan retry) + { + var contentString = JsonSerializer.Serialize(new MainError + { + error = new Error + { + Code = "activityLimitReached", + Message = "Client application has been throttled and should not attempt to repeat the request until an amount of time has elapsed." + } +#if NET5_0_OR_GREATER + }, SourceGenerationContext.Default.MainError); +#else + }); +#endif + var throttleResponse = new HttpResponseMessage + { + StatusCode = (HttpStatusCode)429, + Content = new StringContent(contentString, Encoding.UTF8, Json) + }; + throttleResponse.Headers.RetryAfter = new RetryConditionHeaderValue(retry); + return throttleResponse; + } + + /// + /// Create a HTTP status 503 response message + /// + /// for retry condition header value + /// A object simulating a 503 response + public static HttpResponseMessage Create503Response(TimeSpan retry) + { + var contentString = JsonSerializer.Serialize(new MainError + { + error = new Error + { + Code = "serviceNotAvailable", + Message = "The service is temporarily unavailable for maintenance or is overloaded. You may repeat the request after a delay, the length of which may be specified in a Retry-After header." + } +#if NET5_0_OR_GREATER + }, SourceGenerationContext.Default.MainError); +#else + }); +#endif + var serverUnavailableResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(contentString, Encoding.UTF8, Json) + }; + serverUnavailableResponse.Headers.RetryAfter = new RetryConditionHeaderValue(retry); + return serverUnavailableResponse; + } + + /// + /// Create a HTTP status 502 response message + /// + /// A object simulating a 502 Response + public static HttpResponseMessage Create502BadGatewayResponse() + { + var contentString = JsonSerializer.Serialize(new MainError + { + error = new Error + { + Code = "502" + } +#if NET5_0_OR_GREATER + }, SourceGenerationContext.Default.MainError); +#else + }); +#endif + var badGatewayResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadGateway, + Content = new StringContent(contentString, Encoding.UTF8, Json) + }; + return badGatewayResponse; + } + + /// + /// Create a HTTP status 500 response message + /// + /// A object simulating a 500 Response + public static HttpResponseMessage Create500InternalServerErrorResponse() + { + var contentString = JsonSerializer.Serialize(new MainError + { + error = new Error + { + Code = "generalException", + Message = "There was an internal server error while processing the request." + } +#if NET5_0_OR_GREATER + }, SourceGenerationContext.Default.MainError); +#else + }); +#endif + var internalServerError = new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError, + Content = new StringContent(contentString, Encoding.UTF8, Json) + }; + return internalServerError; + } + + /// + /// Create a HTTP status 504 response message + /// + /// for retry condition header value + /// A object simulating a 504 response + public static HttpResponseMessage Create504GatewayTimeoutResponse(TimeSpan retry) + { + var contentString = JsonSerializer.Serialize(new MainError + { + error = new Error + { + Code = "504", + Message = "The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. May occur together with 503." + } +#if NET5_0_OR_GREATER + }, SourceGenerationContext.Default.MainError); +#else + }); +#endif + var gatewayTimeoutResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.GatewayTimeout, + Content = new StringContent(contentString, Encoding.UTF8, Json) + }; + gatewayTimeoutResponse.Headers.RetryAfter = new RetryConditionHeaderValue(retry); + return gatewayTimeoutResponse; + } + + /// + /// Clean up any thing we created + /// + public new void Dispose() + { + // clean up the response messages + foreach(var response in _knownFailures) + { + response.Dispose(); + } + // Cleanup any base resources + base.Dispose(); + GC.SuppressFinalize(this); + } + } + internal partial class MainError + { + public Error error + { + get; set; + } = new(); + } + /// + /// Private class to model sample responses + /// + internal partial class Error + { + /// + /// The error code + /// + public string? Code + { + get; set; + } + + /// + /// The error message + /// + public string? Message + { + get; set; + } + } +#if NET5_0_OR_GREATER + [JsonSerializable(typeof(MainError))] + internal partial class SourceGenerationContext : JsonSerializerContext + { + } +#endif +} diff --git a/src/http/httpClient/Middleware/CompressionHandler.cs b/src/http/httpClient/Middleware/CompressionHandler.cs new file mode 100644 index 00000000..ee05e2c0 --- /dev/null +++ b/src/http/httpClient/Middleware/CompressionHandler.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// A implementation that handles compression. + /// + public class CompressionHandler : DelegatingHandler + { + internal const string GZip = "gzip"; + + /// + /// Sends a HTTP request. + /// + /// The to be sent. + /// The for the request. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(CompressionHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.compression.enable", true); + } + else + { + activity = null; + } + + try + { + + StringWithQualityHeaderValue gzipQHeaderValue = new StringWithQualityHeaderValue(GZip); + + // Add Accept-encoding: gzip header to incoming request if it doesn't have one. + if(!request.Headers.AcceptEncoding.Contains(gzipQHeaderValue)) + { + request.Headers.AcceptEncoding.Add(gzipQHeaderValue); + } + + HttpResponseMessage response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Decompress response content when Content-Encoding: gzip header is present. + if(ShouldDecompressContent(response)) + { +#if NET5_0_OR_GREATER + StreamContent streamContent = new StreamContent(new GZipStream(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), CompressionMode.Decompress)); +#else + StreamContent streamContent = new StreamContent(new GZipStream(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), CompressionMode.Decompress)); +#endif + // Copy Content Headers to the destination stream content + foreach(var httpContentHeader in response.Content.Headers) + { + streamContent.Headers.TryAddWithoutValidation(httpContentHeader.Key, httpContentHeader.Value); + } + response.Content = streamContent; + } + + return response; + } + finally + { + activity?.Dispose(); + } + } + + /// + /// Checks if a contains a Content-Encoding: gzip header. + /// + /// The to check for header. + /// + private static bool ShouldDecompressContent(HttpResponseMessage httpResponse) + { + return httpResponse.Content?.Headers?.ContentEncoding.Contains(GZip) ?? false; + } + } +} diff --git a/src/http/httpClient/Middleware/HeadersInspectionHandler.cs b/src/http/httpClient/Middleware/HeadersInspectionHandler.cs new file mode 100644 index 00000000..569feead --- /dev/null +++ b/src/http/httpClient/Middleware/HeadersInspectionHandler.cs @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware; + +/// +/// The Headers Inspection Handler allows the developer to inspect the headers of the request and response. +/// +public class HeadersInspectionHandler : DelegatingHandler +{ + private readonly HeadersInspectionHandlerOption _defaultOptions; + + /// + /// Create a new instance of + /// + /// Default options to apply to the handler + public HeadersInspectionHandler(HeadersInspectionHandlerOption? defaultOptions = null) + { + _defaultOptions = defaultOptions ?? new HeadersInspectionHandlerOption(); + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) throw new ArgumentNullException(nameof(request)); + + var options = request.GetRequestOption() ?? _defaultOptions; + + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.headersInspection.enable", true); + } + else + { + activity = null; + } + try + { + if(options.InspectRequestHeaders) + { + foreach(var header in request.Headers) + { + options.RequestHeaders[header.Key] = ConvertHeaderValuesToArray(header.Value); + } + if(request.Content != null) + foreach(var contentHeaders in request.Content.Headers) + { + options.RequestHeaders[contentHeaders.Key] = ConvertHeaderValuesToArray(contentHeaders.Value); + } + } + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + if(options.InspectResponseHeaders) + { + foreach(var header in response.Headers) + { + options.ResponseHeaders[header.Key] = ConvertHeaderValuesToArray(header.Value); + } + if(response.Content != null) + foreach(var contentHeaders in response.Content.Headers) + { + options.ResponseHeaders[contentHeaders.Key] = ConvertHeaderValuesToArray(contentHeaders.Value); + } + } + return response; + } + finally + { + activity?.Dispose(); + } + + static string[] ConvertHeaderValuesToArray(IEnumerable headerValues) + { + var headerValuesList = new List(); + foreach(var value in headerValues) + { + headerValuesList.Add(value); + } + return headerValuesList.ToArray(); + } + } +} diff --git a/src/http/httpClient/Middleware/Options/ChaosHandlerOption.cs b/src/http/httpClient/Middleware/Options/ChaosHandlerOption.cs new file mode 100644 index 00000000..0d718483 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/ChaosHandlerOption.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options +{ + /// + /// The Chaos Handler Option request class + /// + public class ChaosHandlerOption : IRequestOption + { + /// + /// Percentage of responses that will have KnownChaos responses injected, assuming no PlannedChaosFactory is provided + /// + public int ChaosPercentLevel { get; set; } = 10; + /// + /// List of failure responses that potentially could be returned when + /// + public List? KnownChaos { get; set; } + /// + /// Function to return chaos response based on current request. This is used to reproduce detected failure modes. + /// + public Func? PlannedChaosFactory { get; set; } + } +} diff --git a/src/http/httpClient/Middleware/Options/HeadersInspectionHandlerOption.cs b/src/http/httpClient/Middleware/Options/HeadersInspectionHandlerOption.cs new file mode 100644 index 00000000..db48cfc7 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/HeadersInspectionHandlerOption.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +/// +/// The Headers Inspection Option allows the developer to inspect the headers of the request and response. +/// +public class HeadersInspectionHandlerOption : IRequestOption +{ + /// + /// Gets or sets a value indicating whether the request headers should be inspected. + /// + public bool InspectRequestHeaders + { + get; set; + } + /// + /// Gets or sets a value indicating whether the response headers should be inspected. + /// + public bool InspectResponseHeaders + { + get; set; + } + /// + /// Gets the request headers to for the current request. + /// + public RequestHeaders RequestHeaders { get; private set; } = new RequestHeaders(); + /// + /// Gets the response headers for the current request. + /// + public RequestHeaders ResponseHeaders { get; private set; } = new RequestHeaders(); +} diff --git a/src/http/httpClient/Middleware/Options/ParametersNameDecodingOption.cs b/src/http/httpClient/Middleware/Options/ParametersNameDecodingOption.cs new file mode 100644 index 00000000..29815b65 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/ParametersNameDecodingOption.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +/// +/// The ParametersEncodingOption request class +/// +public class ParametersNameDecodingOption : IRequestOption +{ + /// + /// Whether to decode the specified characters in the request query parameters names + /// + public bool Enabled { get; set; } = true; + /// + /// The list of characters to decode in the request query parameters names before executing the request + /// + public List ParametersToDecode { get; set; } = new() { '$' }; // '.', '-', '~' already being decoded by Uri +} diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs new file mode 100644 index 00000000..9a9fa797 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options +{ + /// + /// The redirect request option class + /// + public class RedirectHandlerOption: IRequestOption + { + private const int DefaultMaxRedirect = 5; + private const int MaxMaxRedirect = 20; + private int _maxRedirect = DefaultMaxRedirect; + + /// + /// The maximum number of redirects with a maximum value of 20. This defaults to 5 redirects. + /// + public int MaxRedirect + { + get + { + return _maxRedirect; + } + set + { + if(value > MaxMaxRedirect) + throw new InvalidOperationException($"Maximum value for {nameof(MaxRedirect)} property exceeded "); + + _maxRedirect = value; + } + } + + /// + /// A delegate that's called to determine whether a response should be redirected or not. The delegate method should accept as it's parameter and return a . This defaults to true. + /// + public Func ShouldRedirect { get; set; } = (response) => true; + + /// + /// A boolean value to determine if we redirects are allowed if the scheme changes(e.g. https to http). Defaults to false. + /// + public bool AllowRedirectOnSchemeChange { get; set; } = false; + } +} diff --git a/src/http/httpClient/Middleware/Options/RetryHandlerOption.cs b/src/http/httpClient/Middleware/Options/RetryHandlerOption.cs new file mode 100644 index 00000000..7f0a7cab --- /dev/null +++ b/src/http/httpClient/Middleware/Options/RetryHandlerOption.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options +{ + /// + /// The retry request option class + /// + public class RetryHandlerOption : IRequestOption + { + internal const int DefaultDelay = 3; + internal const int DefaultMaxRetry = 3; + internal const int MaxMaxRetry = 10; + internal const int MaxDelay = 180; + private int _maxRetry = DefaultMaxRetry; + private int _delay = DefaultDelay; + + /// + /// The waiting time in seconds before retrying a request with a maximum value of 180 seconds. This defaults to 3 seconds. + /// + public int Delay + { + get + { + return _delay; + } + set + { + if(value > MaxDelay) + { + throw new InvalidOperationException($"Maximum value for {nameof(MaxDelay)} property exceeded "); + } + + _delay = value; + } + } + + /// + /// The maximum number of retries for a request with a maximum value of 10. This defaults to 3. + /// + public int MaxRetry + { + get + { + return _maxRetry; + } + set + { + if(value > MaxMaxRetry) + { + throw new InvalidOperationException($"Maximum value for {nameof(MaxMaxRetry)} property exceeded "); + } + _maxRetry = value; + } + } + + /// + /// The maximum time allowed for request retries. + /// + public TimeSpan RetriesTimeLimit { get; set; } = TimeSpan.Zero; + + /// + /// A delegate that's called to determine whether a request should be retried or not. + /// The delegate method should accept a delay time in seconds of, number of retry attempts and as it's parameters and return a . This defaults to false + /// + public Func ShouldRetry { get; set; } = (_, _, _) => false; + } +} diff --git a/src/http/httpClient/Middleware/Options/TelemetryHandlerOption.cs b/src/http/httpClient/Middleware/Options/TelemetryHandlerOption.cs new file mode 100644 index 00000000..8faabd88 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/TelemetryHandlerOption.cs @@ -0,0 +1,17 @@ +using System; +using System.Net.Http; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options +{ + /// + /// The Telemetry request option class + /// + public class TelemetryHandlerOption : IRequestOption + { + /// + /// A delegate that's called to configure the with the appropriate telemetry values. + /// + public Func TelemetryConfigurator { get; set; } = (request) => request; + } +} diff --git a/src/http/httpClient/Middleware/Options/UriReplacementHandlerOption.cs b/src/http/httpClient/Middleware/Options/UriReplacementHandlerOption.cs new file mode 100644 index 00000000..6198111d --- /dev/null +++ b/src/http/httpClient/Middleware/Options/UriReplacementHandlerOption.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +/// +/// Interface for making URI replacements. +/// +public interface IUriReplacementHandlerOption : IRequestOption +{ + /// + /// Check if URI replacement is enabled for the option. + /// + /// true if replacement is enabled or false otherwise. + bool IsEnabled(); + + /// + /// Accepts a URI and returns a new URI with all replacements applied. + /// + /// The URI to apply replacements to + /// A new URI with all replacements applied. + Uri? Replace(Uri? original); +} + +/// +/// Url replacement options. +/// +public class UriReplacementHandlerOption : IUriReplacementHandlerOption +{ + private readonly bool isEnabled; + + private readonly IEnumerable> replacementPairs; + + /// + /// Creates a new instance of UriReplacementOption. + /// + /// Whether replacement is enabled. + /// Replacements with the key being a string to match against and the value being the replacement. + public UriReplacementHandlerOption(bool isEnabled, IEnumerable> replacementPairs) + { + this.isEnabled = isEnabled; + this.replacementPairs = replacementPairs; + } + + /// + /// Creates a new instance of UriReplacementOption with no replacements. + /// + /// Whether replacement is enabled. + /// Replacement is disabled by default. + public UriReplacementHandlerOption(bool isEnabled = false) : this(isEnabled, Array.Empty>()) { } + + /// + public bool IsEnabled() + { + return isEnabled; + } + + /// + public Uri? Replace(Uri? original) + { + if(original is null) return null; + + if(!isEnabled) + { + return original; + } + + var newUrl = new UriBuilder(original); + foreach(var pair in replacementPairs) + { + newUrl.Path = newUrl.Path.Replace(pair.Key, pair.Value); + } + + return newUrl.Uri; + } +} diff --git a/src/http/httpClient/Middleware/Options/UserAgentHandlerOption.cs b/src/http/httpClient/Middleware/Options/UserAgentHandlerOption.cs new file mode 100644 index 00000000..de758961 --- /dev/null +++ b/src/http/httpClient/Middleware/Options/UserAgentHandlerOption.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options +{ + /// + /// The User Agent Handler Option request class + /// + public class UserAgentHandlerOption : IRequestOption + { + /// + /// Whether to append the kiota version to the user agent header + /// + public bool Enabled { get; set; } = true; + /// + /// The product name to append to the user agent header + /// + public string ProductName { get; set; } = "kiota-dotnet"; + /// + /// The product version to append to the user agent header + /// + public string ProductVersion { get; set; } = Microsoft.Kiota.Http.Generated.Version.Current(); + } +} diff --git a/src/http/httpClient/Middleware/ParametersNameDecodingHandler.cs b/src/http/httpClient/Middleware/ParametersNameDecodingHandler.cs new file mode 100644 index 00000000..61c3f69a --- /dev/null +++ b/src/http/httpClient/Middleware/ParametersNameDecodingHandler.cs @@ -0,0 +1,116 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware; + +/// +/// This handlers decodes special characters in the request query parameters that had to be encoded due to RFC 6570 restrictions names before executing the request. +/// +public class ParametersNameDecodingHandler : DelegatingHandler +{ + /// + /// The options to use when decoding parameters names in URLs + /// + internal ParametersNameDecodingOption EncodingOptions + { + get; set; + } + /// + /// Constructs a new + /// + /// An OPTIONAL to configure + public ParametersNameDecodingHandler(ParametersNameDecodingOption? options = default) + { + EncodingOptions = options ?? new(); + } + + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var options = request.GetRequestOption() ?? EncodingOptions; + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource.StartActivity($"{nameof(ParametersNameDecodingHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.parameters_name_decoding.enable", true); + } + else + { + activity = null; + } + try + { + if(!request.RequestUri!.Query.Contains("%") || + !options.Enabled || + options.ParametersToDecode == null || options.ParametersToDecode.Count == 0) + { + return base.SendAsync(request, cancellationToken); + } + + var originalUri = request.RequestUri; + var query = DecodeUriEncodedString(originalUri.Query, options.ParametersToDecode.ToArray()); + var decodedUri = new UriBuilder(originalUri.Scheme, originalUri.Host, originalUri.Port, originalUri.AbsolutePath, query).Uri; + request.RequestUri = decodedUri; + return base.SendAsync(request, cancellationToken); + } + finally + { + activity?.Dispose(); + } + } + + internal static string? DecodeUriEncodedString(string? original, char[] charactersToDecode) + { + if(string.IsNullOrEmpty(original) || charactersToDecode == null || charactersToDecode.Length == 0) + return original; + + var symbolsToReplace = new List<(string, string)>(); + foreach(var character in charactersToDecode) + { + var symbol = ($"%{Convert.ToInt32(character):X}", character.ToString()); + if(original?.Contains(symbol.Item1) ?? false) + { + symbolsToReplace.Add(symbol); + } + } + + var encodedParameterValues = new List(); + var parts = original?.TrimStart('?').Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries); + if(parts is null) return original; + foreach(var part in parts) + { + var parameter = part.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries)[0]; + if(parameter.Contains("%")) // only pull out params with `%` (encoded) + { + encodedParameterValues.Add(parameter); + } + } + + foreach(var parameter in encodedParameterValues) + { + var updatedParameterName = parameter; + foreach(var symbolToReplace in symbolsToReplace) + { + if(parameter.Contains(symbolToReplace.Item1)) + { + updatedParameterName = updatedParameterName.Replace(symbolToReplace.Item1, symbolToReplace.Item2); + } + } + original = original?.Replace(parameter, updatedParameterName); + } + + return original; + } +} diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs new file mode 100644 index 00000000..07cc8718 --- /dev/null +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -0,0 +1,187 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// A implementation for handling redirection of requests. + /// + public class RedirectHandler : DelegatingHandler + { + /// + /// Constructs a new + /// + /// An OPTIONAL to configure + public RedirectHandler(RedirectHandlerOption? redirectOption = null) + { + RedirectOption = redirectOption ?? new RedirectHandlerOption(); + } + + /// + /// RedirectOption property + /// + internal RedirectHandlerOption RedirectOption + { + get; private set; + } + + /// + /// Sends the Request and handles redirect responses if needed + /// + /// The to send. + /// The for the request. + /// The . + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) throw new ArgumentNullException(nameof(request)); + + var redirectOption = request.GetRequestOption() ?? RedirectOption; + + ActivitySource? activitySource; + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.redirect.enable", true); + } + else + { + activity = null; + activitySource = null; + } + try + { + + // send request first time to get response + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // check response status code and redirect handler option + if(ShouldRedirect(response, redirectOption)) + { + if(response.Headers.Location == null) + { + throw new InvalidOperationException( + "Unable to perform redirect as Location Header is not set in response", + new Exception($"No header present in response with status code {response.StatusCode}")); + } + + var redirectCount = 0; + + while(redirectCount < redirectOption.MaxRedirect) + { + using var redirectActivity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)} - redirect {redirectCount}"); + redirectActivity?.SetTag("com.microsoft.kiota.handler.redirect.count", redirectCount); + redirectActivity?.SetTag("http.status_code", response.StatusCode); + // Drain response content to free responses. + if(response.Content != null) + { +#if NET5_0_OR_GREATER + await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); +#else + await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); +#endif + } + + // general clone request with internal CloneAsync (see CloneAsync for details) extension method + var originalRequest = response.RequestMessage; + if(originalRequest == null) + { + return response;// We can't clone the original request to replay it. + } + var newRequest = await originalRequest.CloneAsync(cancellationToken).ConfigureAwait(false); + + // status code == 303: change request method from post to get and content to be null + if(response.StatusCode == HttpStatusCode.SeeOther) + { + newRequest.Content = null; + newRequest.Method = HttpMethod.Get; + } + + // Set newRequestUri from response + if(response.Headers.Location?.IsAbsoluteUri ?? false) + { + newRequest.RequestUri = response.Headers.Location; + } + else + { + var baseAddress = newRequest.RequestUri?.GetComponents(UriComponents.SchemeAndServer | UriComponents.KeepDelimiter, UriFormat.Unescaped); + newRequest.RequestUri = new Uri(baseAddress + response.Headers.Location); + } + + // Remove Auth if http request's scheme or host changes + if(!newRequest.RequestUri.Host.Equals(request.RequestUri?.Host) || + !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme)) + { + newRequest.Headers.Authorization = null; + } + + // If scheme has changed. Ensure that this has been opted in for security reasons + if(!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) + { + throw new InvalidOperationException( + $"Redirects with changing schemes not allowed by default. You can change this by modifying the {nameof(redirectOption.AllowRedirectOnSchemeChange)} option", + new Exception($"Scheme changed from {request.RequestUri?.Scheme} to {newRequest.RequestUri.Scheme}.")); + } + + // Send redirect request to get response + response = await base.SendAsync(newRequest, cancellationToken).ConfigureAwait(false); + + // Check response status code + if(ShouldRedirect(response, redirectOption)) + { + redirectCount++; + } + else + { + return response; + } + } + + throw new InvalidOperationException( + "Too many redirects performed", + new Exception($"Max redirects exceeded. Redirect count : {redirectCount}")); + } + return response; + } + finally + { + activity?.Dispose(); + } + } + + private bool ShouldRedirect(HttpResponseMessage responseMessage, RedirectHandlerOption redirectOption) + { + return IsRedirect(responseMessage.StatusCode) && redirectOption.ShouldRedirect(responseMessage) && redirectOption.MaxRedirect > 0; + } + + /// + /// Checks whether is redirected + /// + /// The . + /// Bool value for redirection or not + private static bool IsRedirect(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.MovedPermanently => true, + HttpStatusCode.Found => true, + HttpStatusCode.SeeOther => true, + HttpStatusCode.TemporaryRedirect => true, + (HttpStatusCode)308 => true, + _ => false + }; + } + + } +} diff --git a/src/http/httpClient/Middleware/RetryHandler.cs b/src/http/httpClient/Middleware/RetryHandler.cs new file mode 100644 index 00000000..47879bc9 --- /dev/null +++ b/src/http/httpClient/Middleware/RetryHandler.cs @@ -0,0 +1,259 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// A implementation using standard .NET libraries. + /// + public class RetryHandler : DelegatingHandler + { + private const string RetryAfter = "Retry-After"; + private const string RetryAttempt = "Retry-Attempt"; + + /// + /// RetryOption property + /// + internal RetryHandlerOption RetryOption + { + get; set; + } + + /// + /// Construct a new + /// + /// An OPTIONAL to configure + public RetryHandler(RetryHandlerOption? retryOption = null) + { + RetryOption = retryOption ?? new RetryHandlerOption(); + } + + /// + /// Send a HTTP request + /// + /// The HTTP requestneeds to be sent. + /// The for the request. + /// Thrown when too many retries are performed. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + var retryOption = request.GetRequestOption() ?? RetryOption; + ActivitySource? activitySource; + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(RetryHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.retry.enable", true); + } + else + { + activity = null; + activitySource = null; + } + + try + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Check whether retries are permitted and that the MaxRetry value is a non - negative, non - zero value + if(request.IsBuffered() && retryOption.MaxRetry > 0 && (ShouldRetry(response.StatusCode) || retryOption.ShouldRetry(retryOption.Delay, 0, response))) + { + response = await SendRetryAsync(response, retryOption, cancellationToken, activitySource).ConfigureAwait(false); + } + + return response; + } + finally + { + activity?.Dispose(); + } + } + + /// + /// Retry sending the HTTP request + /// + /// The which is returned and includes the HTTP request needs to be retried. + /// The for the retry. + /// The for the retry. + /// The for the retry. + /// Thrown when too many retries are performed." + /// + private async Task SendRetryAsync(HttpResponseMessage response, RetryHandlerOption retryOption, CancellationToken cancellationToken, ActivitySource? activitySource) + { + int retryCount = 0; + TimeSpan cumulativeDelay = TimeSpan.Zero; + List exceptions = new(); + + while(retryCount < retryOption.MaxRetry) + { + exceptions.Add(await GetInnerExceptionAsync(response).ConfigureAwait(false)); + using var retryActivity = activitySource?.StartActivity($"{nameof(RetryHandler)}_{nameof(SendAsync)} - attempt {retryCount}"); + retryActivity?.SetTag("http.retry_count", retryCount); + retryActivity?.SetTag("http.status_code", response.StatusCode); + + // Call Delay method to get delay time from response's Retry-After header or by exponential backoff + Task delay = RetryHandler.Delay(response, retryCount, retryOption.Delay, out double delayInSeconds, cancellationToken); + + // If client specified a retries time limit, let's honor it + if(retryOption.RetriesTimeLimit > TimeSpan.Zero) + { + // Get the cumulative delay time + cumulativeDelay += TimeSpan.FromSeconds(delayInSeconds); + + // Check whether delay will exceed the client-specified retries time limit value + if(cumulativeDelay > retryOption.RetriesTimeLimit) + { + return response; + } + } + + // general clone request with internal CloneAsync (see CloneAsync for details) extension method + var originalRequest = response.RequestMessage; + if(originalRequest == null) + { + return response;// We can't clone the original request to replay it. + } + var request = await originalRequest.CloneAsync(cancellationToken).ConfigureAwait(false); + + // Increase retryCount and then update Retry-Attempt in request header + retryCount++; + AddOrUpdateRetryAttempt(request, retryCount); + + // Delay time + await delay.ConfigureAwait(false); + + // Call base.SendAsync to send the request + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if(!(request.IsBuffered() && (ShouldRetry(response.StatusCode) || retryOption.ShouldRetry(retryOption.Delay, retryCount, response)))) + { + return response; + } + } + + exceptions.Add(await GetInnerExceptionAsync(response).ConfigureAwait(false)); + + throw new AggregateException($"Too many retries performed. More than {retryCount} retries encountered while sending the request.", exceptions); + } + + /// + /// Update Retry-Attempt header in the HTTP request + /// + /// The needs to be sent. + /// Retry times + private static void AddOrUpdateRetryAttempt(HttpRequestMessage request, int retryCount) + { + if(request.Headers.Contains(RetryAttempt)) + { + request.Headers.Remove(RetryAttempt); + } + request.Headers.Add(RetryAttempt, retryCount.ToString()); + } + + /// + /// Delay task operation for timed-retries based on Retry-After header in the response or exponential back-off + /// + /// The returned. + /// The retry counts + /// Delay value in seconds. + /// + /// The cancellationToken for the Http request + /// The for delay operation. + internal static Task Delay(HttpResponseMessage response, int retryCount, int delay, out double delayInSeconds, CancellationToken cancellationToken) + { + delayInSeconds = delay; + if(response.Headers.TryGetValues(RetryAfter, out IEnumerable? values)) + { + using IEnumerator v = values.GetEnumerator(); + string retryAfter = v.MoveNext() ? v.Current : throw new InvalidOperationException("Retry-After header is empty."); + // the delay could be in the form of a seconds or a http date. See https://httpwg.org/specs/rfc7231.html#header.retry-after + if(int.TryParse(retryAfter, out int delaySeconds)) + { + delayInSeconds = delaySeconds; + } + else if(DateTime.TryParseExact(retryAfter, CultureInfo.InvariantCulture.DateTimeFormat.RFC1123Pattern, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateTime)) + { + var timeSpan = dateTime - DateTime.Now; + // ensure the delay is a positive span otherwise use the exponential back-off + delayInSeconds = timeSpan.Seconds > 0 ? timeSpan.Seconds : CalculateExponentialDelay(retryCount, delay); + } + } + else + { + delayInSeconds = CalculateExponentialDelay(retryCount, delay); + } + + TimeSpan delayTimeSpan = TimeSpan.FromSeconds(Math.Min(delayInSeconds, RetryHandlerOption.MaxDelay)); + delayInSeconds = delayTimeSpan.TotalSeconds; + return Task.Delay(delayTimeSpan, cancellationToken); + } + + /// + /// Calculates the delay based on the exponential back off + /// + /// The retry count + /// The base to use as a delay + /// + private static double CalculateExponentialDelay(int retryCount, int delay) + { + return Math.Pow(2, retryCount) * delay; + } + + /// + /// Check the HTTP status to determine whether it should be retried or not. + /// + /// The returned. + /// + private static bool ShouldRetry(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.ServiceUnavailable => true, + HttpStatusCode.GatewayTimeout => true, + (HttpStatusCode)429 => true, + _ => false + }; + } + + private static async Task GetInnerExceptionAsync(HttpResponseMessage response) + { + string? errorMessage = null; + + // Drain response content to free connections. Need to perform this + // before retry attempt and before the TooManyRetries ServiceException. + if(response.Content != null) + { + errorMessage = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + + var headersDictionary = new Dictionary>(); + foreach(var header in response.Headers) + { + headersDictionary.Add(header.Key, header.Value); + } + + return new ApiException($"HTTP request failed with status code: {response.StatusCode}.{errorMessage}") + { + ResponseStatusCode = (int)response.StatusCode, + ResponseHeaders = headersDictionary, + }; + } + } +} diff --git a/src/http/httpClient/Middleware/TelemetryHandler.cs b/src/http/httpClient/Middleware/TelemetryHandler.cs new file mode 100644 index 00000000..2bcd36ab --- /dev/null +++ b/src/http/httpClient/Middleware/TelemetryHandler.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// A implementation using standard .NET libraries. + /// + public class TelemetryHandler : DelegatingHandler + { + private readonly TelemetryHandlerOption _telemetryHandlerOption; + + /// + /// The constructor + /// + /// The instance to configure the telemetry + public TelemetryHandler(TelemetryHandlerOption? telemetryHandlerOption = null) + { + this._telemetryHandlerOption = telemetryHandlerOption ?? new TelemetryHandlerOption(); + } + + /// + /// Send a HTTP request + /// + /// The HTTP requestneeds to be sent. + /// The for the request. + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + var telemetryHandlerOption = request.GetRequestOption() ?? _telemetryHandlerOption; + + // use the enriched request from the handler + if(telemetryHandlerOption.TelemetryConfigurator != null) + { + var enrichedRequest = telemetryHandlerOption.TelemetryConfigurator(request); + return await base.SendAsync(enrichedRequest, cancellationToken).ConfigureAwait(false); + } + + // Just forward the request if TelemetryConfigurator was intentionally set to null + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/http/httpClient/Middleware/UriReplacementHandler.cs b/src/http/httpClient/Middleware/UriReplacementHandler.cs new file mode 100644 index 00000000..4bb2fe38 --- /dev/null +++ b/src/http/httpClient/Middleware/UriReplacementHandler.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware; + +/// +/// Replaces a portion of the URL. +/// +/// A type with the rules used to perform a URI replacement. +public class UriReplacementHandler : DelegatingHandler where TUriReplacementHandlerOption : IUriReplacementHandlerOption +{ + private readonly TUriReplacementHandlerOption? _uriReplacement; + + /// + /// Creates a new UriReplacementHandler. + /// + /// An object with the URI replacement rules. + public UriReplacementHandler(TUriReplacementHandlerOption? uriReplacement = default) + { + this._uriReplacement = uriReplacement; + } + + /// + protected override async Task SendAsync( + HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + var uriReplacement = request.GetRequestOption() ?? _uriReplacement; + + // If there is no URI replacement to apply, then just skip this handler. + if(uriReplacement is null) + { + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource.StartActivity($"{nameof(UriReplacementHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.uri_replacement.enable", uriReplacement.IsEnabled()); + } + else + { + activity = null; + } + + try + { + request.RequestUri = uriReplacement.Replace(request.RequestUri); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + activity?.Dispose(); + } + } +} diff --git a/src/http/httpClient/Middleware/UserAgentHandler.cs b/src/http/httpClient/Middleware/UserAgentHandler.cs new file mode 100644 index 00000000..e62276eb --- /dev/null +++ b/src/http/httpClient/Middleware/UserAgentHandler.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// appends the current library version to the user agent header. + /// + public class UserAgentHandler : DelegatingHandler + { + private readonly UserAgentHandlerOption _userAgentOption; + /// + /// Creates a new instance of the class + /// + /// The instance to configure the user agent extension + public UserAgentHandler(UserAgentHandlerOption? userAgentHandlerOption = null) + { + _userAgentOption = userAgentHandlerOption ?? new UserAgentHandlerOption(); + } + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + Activity? activity; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(UserAgentHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.useragent.enable", true); + } + else + { + activity = null; + } + + try + { + var userAgentHandlerOption = request.GetRequestOption() ?? _userAgentOption; + + bool isProductNamePresent = false; + foreach(var userAgent in request.Headers.UserAgent) + { + if(userAgentHandlerOption.ProductName.Equals(userAgent.Product?.Name, StringComparison.OrdinalIgnoreCase)) + { + isProductNamePresent = true; + break; + } + } + + if(userAgentHandlerOption.Enabled && !isProductNamePresent) + { + request.Headers.UserAgent.Add(new ProductInfoHeaderValue(userAgentHandlerOption.ProductName, userAgentHandlerOption.ProductVersion)); + } + return base.SendAsync(request, cancellationToken); + } + finally + { + activity?.Dispose(); + } + } + } +} diff --git a/src/http/httpClient/ObservabilityOptions.cs b/src/http/httpClient/ObservabilityOptions.cs new file mode 100644 index 00000000..1bb114b0 --- /dev/null +++ b/src/http/httpClient/ObservabilityOptions.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary; + +/// +/// Holds the tracing, metrics and logging configuration for the request adapter +/// +public class ObservabilityOptions : IRequestOption { + /// + /// Gets or sets a value indicating whether to include attributes which could contain EUII information. + /// + public bool IncludeEUIIAttributes { get; set; } + private static readonly Lazy _name = new Lazy(() => typeof(ObservabilityOptions).Namespace!); + /// + /// Gets the observability name to use for the tracer + /// + public string TracerInstrumentationName => _name.Value; +} diff --git a/src/http/httpClient/Properties/AssemblyInfo.cs b/src/http/httpClient/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3ef9220f --- /dev/null +++ b/src/http/httpClient/Properties/AssemblyInfo.cs @@ -0,0 +1,24 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// In SDK-style projects such as this one, several assembly attributes that were historically +// defined in this file are now automatically added during build and populated with +// values defined in project properties. For details of which attributes are included +// and how to customise this process see: https://aka.ms/assembly-info-properties + + +// Setting ComVisible to false makes the types in this assembly not visible to COM +// components. If you need to access a type in this assembly from COM, set the ComVisible +// attribute to true on that type. + +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM. + +[assembly: Guid("b0c91cbc-af85-4c64-b989-d320e6989b2a")] + +#if DEBUG +[assembly: InternalsVisibleTo("Microsoft.Kiota.Http.HttpClientLibrary.Tests")] +#else +[assembly: InternalsVisibleTo("Microsoft.Kiota.Http.HttpClientLibrary.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] +#endif \ No newline at end of file diff --git a/src/http/httpClient/README.md b/src/http/httpClient/README.md new file mode 100644 index 00000000..30b6c0eb --- /dev/null +++ b/src/http/httpClient/README.md @@ -0,0 +1,39 @@ +# Kiota Http Library for dotnet + +The Kiota HTTP Library for dotnet is the dotnet HTTP library implementation with [HttpClient](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=net-6.0). + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a HTTP package to make HTTP requests to an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Kiota Http Library for dotnet + +```shell +dotnet add package Microsoft.Kiota.Http.HttpClientLibrary +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Http.HttpClientLibrary.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/serialization/form/Changelog-old.md b/src/serialization/form/Changelog-old.md new file mode 100644 index 00000000..11ebdd32 --- /dev/null +++ b/src/serialization/form/Changelog-old.md @@ -0,0 +1,136 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.2.5] - 2024-06-26 + +### Changed + +- Fixed a bug where new parse nodes would be missing event receivers. [#153](https://github.com/microsoft/kiota-serialization-form-dotnet/issues/153) + +## [1.2.4] - 2024-05-23 + +### Changed + +- Remove all LINQ usage from repo + +## [1.2.3] - 2024-05-23 + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [microsoft/kiota-http-dotnet#256](https://github.com/microsoft/kiota-http-dotnet/issues/258) + +## [1.2.2] - 2024-05-21 + +### Changed + +- Updated serialization and deserialization of enum collection to remove LINQ to reduce NativeAOT output size + +## [1.2.1] - 2024-05-20 + +### Changed + +- Updated serialization and deserialization of enums to remove LINQ to resolve NativeAOT compatibility issue + +## [1.2.0] - 2024-05-13 + +### Added + +- Implements IAsyncParseNodeFactory interface which adds async support + +## [1.1.6] - 2024-04-19 + +### Changed + +- Switch to license expression & bump abstractions () + +## [1.1.5] - 2024-02-27 + +### Changed + +- Reduced `DynamicallyAccessedMembers` scope for enum methods to prevent ILC warnings. + +## [1.1.4] - 2024-02-26 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.1.3] - 2024-02-05 + +### Changed + +- Fixes `IsTrimmable` property on the project. + +## [1.1.2] - 2024-01-30 + +### Changed + +- Fixed some AOT warnings due to reflection use on enum types. + +## [1.1.1] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.1.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +## [1.0.2] - 2023-03-10 + +### Changed + +- Bumps abstraction dependency + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +### Changed + +## [1.0.0-rc.5] - 2023-02-20 + +### Changed + +- Adds support rendering collection of values + +## [1.0.0-rc.4] - 2023-01-27 + +### Changed + +- Relaxed nullability tolerance when merging objects for composed types. + +## [1.0.0-rc.3] - 2023-01-17 + +### Changed + +- Adds support for nullable reference types + +## [1.0.0-rc.2] - 2022-12-16 + +### Changed + +- Fixed key encoding. + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +## [1.0.0-preview.1] - 2022-12-15 + +### Added + +- Initial Nuget release. diff --git a/src/serialization/form/FormParseNode.cs b/src/serialization/form/FormParseNode.cs new file mode 100644 index 00000000..0ed5cf3b --- /dev/null +++ b/src/serialization/form/FormParseNode.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using System.Reflection; +using System.Xml; +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Form; +/// Represents a parse node that can be used to parse a form url encoded string. +public class FormParseNode : IParseNode +{ + private readonly string RawValue; + private string DecodedValue => Uri.UnescapeDataString(RawValue); + private readonly Dictionary Fields; + /// Initializes a new instance of the class. + /// The raw value to parse. + /// Thrown when the is null. + public FormParseNode(string rawValue) + { + RawValue = rawValue ?? throw new ArgumentNullException(nameof(rawValue)); + Fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + char[] pairDelimiter = new char[] { '=' }; + string[] pairs = rawValue.Split(new char[] { '&' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string pair in pairs) + { + string[] keyValue = pair.Split(pairDelimiter, StringSplitOptions.RemoveEmptyEntries); + if (keyValue.Length == 2) + { + string key = SanitizeKey(keyValue[0]); + string value = keyValue[1].Trim(); + + if (Fields.ContainsKey(key)) + { + Fields[key] += $",{value}"; + } + else + { + Fields.Add(key, value); + } + } + } + } + + private static string SanitizeKey(string key) { + if (string.IsNullOrEmpty(key)) return key; + return Uri.UnescapeDataString(key.Trim()); + } + /// + public Action? OnBeforeAssignFieldValues { get; set; } + /// + public Action? OnAfterAssignFieldValues { get; set; } + /// + public bool? GetBoolValue() => bool.TryParse(DecodedValue, out var result) && result; + /// + public byte[]? GetByteArrayValue() { + var rawValue = DecodedValue; + if(string.IsNullOrEmpty(rawValue)) return null; + return Convert.FromBase64String(rawValue); + } + /// + public byte? GetByteValue() => byte.TryParse(DecodedValue, out var result) ? result : null; + /// + public IParseNode? GetChildNode(string identifier) => Fields.TryGetValue(SanitizeKey(identifier), out var value) ? + new FormParseNode(value){ + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + } : null; + /// + public IEnumerable GetCollectionOfObjectValues(ParsableFactory factory) where T : IParsable => throw new InvalidOperationException("collections are not supported with uri form encoding"); + + private static readonly Type booleanType = typeof(bool?); + private static readonly Type byteType = typeof(byte?); + private static readonly Type sbyteType = typeof(sbyte?); + private static readonly Type stringType = typeof(string); + private static readonly Type intType = typeof(int?); + private static readonly Type decimalType = typeof(decimal?); + private static readonly Type floatType = typeof(float?); + private static readonly Type doubleType = typeof(double?); + private static readonly Type guidType = typeof(Guid?); + private static readonly Type dateTimeOffsetType = typeof(DateTimeOffset?); + private static readonly Type timeSpanType = typeof(TimeSpan?); + private static readonly Type dateType = typeof(Date?); + private static readonly Type timeType = typeof(Time?); + + /// + /// Get the collection of primitives of type from the form node + /// + /// A collection of objects + public IEnumerable GetCollectionOfPrimitiveValues() + { + var genericType = typeof(T); + var primitiveValueCollection = DecodedValue.Split(new[] { ',' } , StringSplitOptions.RemoveEmptyEntries); + foreach(var collectionValue in primitiveValueCollection) + { + var currentParseNode = new FormParseNode(collectionValue) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }; + if(genericType == booleanType) + yield return (T)(object)currentParseNode.GetBoolValue()!; + else if(genericType == byteType) + yield return (T)(object)currentParseNode.GetByteValue()!; + else if(genericType == sbyteType) + yield return (T)(object)currentParseNode.GetSbyteValue()!; + else if(genericType == stringType) + yield return (T)(object)currentParseNode.GetStringValue()!; + else if(genericType == intType) + yield return (T)(object)currentParseNode.GetIntValue()!; + else if(genericType == floatType) + yield return (T)(object)currentParseNode.GetFloatValue()!; + else if(genericType == doubleType) + yield return (T)(object)currentParseNode.GetDoubleValue()!; + else if(genericType == decimalType) + yield return (T)(object)currentParseNode.GetDecimalValue()!; + else if(genericType == guidType) + yield return (T)(object)currentParseNode.GetGuidValue()!; + else if(genericType == dateTimeOffsetType) + yield return (T)(object)currentParseNode.GetDateTimeOffsetValue()!; + else if(genericType == timeSpanType) + yield return (T)(object)currentParseNode.GetTimeSpanValue()!; + else if(genericType == dateType) + yield return (T)(object)currentParseNode.GetDateValue()!; + else if(genericType == timeType) + yield return (T)(object)currentParseNode.GetTimeValue()!; + else + throw new InvalidOperationException($"unknown type for deserialization {genericType.FullName}"); + } + } + /// + public DateTimeOffset? GetDateTimeOffsetValue() => DateTimeOffset.TryParse(DecodedValue, out var result) ? result : null; + /// + public Date? GetDateValue() => DateTime.TryParse(DecodedValue, out var result) ? new Date(result) : null; + /// + public decimal? GetDecimalValue() => decimal.TryParse(DecodedValue, out var result) ? result : null; + /// + public double? GetDoubleValue() => double.TryParse(DecodedValue, out var result) ? result : null; + /// + public float? GetFloatValue() => float.TryParse(DecodedValue, out var result) ? result : null; + /// + public Guid? GetGuidValue() => Guid.TryParse(DecodedValue, out var result) ? result : null; + /// + public int? GetIntValue() => int.TryParse(DecodedValue, out var result) ? result : null; + /// + public long? GetLongValue() => long.TryParse(DecodedValue, out var result) ? result : null; + /// + public T GetObjectValue(ParsableFactory factory) where T : IParsable { + var item = factory(this); + OnBeforeAssignFieldValues?.Invoke(item); + AssignFieldValues(item); + OnAfterAssignFieldValues?.Invoke(item); + return item; + } + private void AssignFieldValues(T item) where T : IParsable + { + if(Fields.Count == 0) return; + IDictionary? itemAdditionalData = null; + if(item is IAdditionalDataHolder holder) + { + holder.AdditionalData ??= new Dictionary(); + itemAdditionalData = holder.AdditionalData; + } + var fieldDeserializers = item.GetFieldDeserializers(); + + foreach(var fieldValue in Fields) + { + if(fieldDeserializers.TryGetValue(fieldValue.Key, out var fieldDeserializer)) + { + if("null".Equals(fieldValue.Value, StringComparison.OrdinalIgnoreCase)) + continue;// If the property is already null just continue. As calling functions like GetDouble,GetBoolValue do not process null. + + Debug.WriteLine($"found property {fieldValue.Key} to deserialize"); + fieldDeserializer.Invoke(new FormParseNode(fieldValue.Value) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }); + } + else if (itemAdditionalData != null) + { + Debug.WriteLine($"found additional property {fieldValue.Key} to deserialize"); + IDictionaryExtensions.TryAdd(itemAdditionalData, fieldValue.Key, fieldValue.Value); + } + else + { + Debug.WriteLine($"found additional property {fieldValue.Key} to deserialize but the model doesn't support additional data"); + } + } + } + + /// + public sbyte? GetSbyteValue() => sbyte.TryParse(DecodedValue, out var result) ? result : null; + + /// + public string GetStringValue() => DecodedValue; + + /// + public TimeSpan? GetTimeSpanValue() { + var rawString = DecodedValue; + if(string.IsNullOrEmpty(rawString)) + return null; + + // Parse an ISO8601 duration.http://en.wikipedia.org/wiki/ISO_8601#Durations to a TimeSpan + return XmlConvert.ToTimeSpan(rawString); + } + + /// + public Time? GetTimeValue() => DateTime.TryParse(DecodedValue, out var result) ? new Time(result) : null; + +#if NET5_0_OR_GREATER + IEnumerable IParseNode.GetCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() +#else + IEnumerable IParseNode.GetCollectionOfEnumValues() +#endif + { + foreach (var v in DecodedValue.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + yield return GetEnumValueInternal(v); + } + +#if NET5_0_OR_GREATER + T? IParseNode.GetEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() +#else + T? IParseNode.GetEnumValue() +#endif + { + return GetEnumValueInternal(DecodedValue); + } + + private static T? GetEnumValueInternal(string rawValue) where T : struct, Enum + { + if(string.IsNullOrEmpty(rawValue)) + return null; + if (typeof(T).IsDefined(typeof(FlagsAttribute))) + { + ReadOnlySpan valueSpan = rawValue.AsSpan(); + int value = 0; + while(valueSpan.Length > 0) + { + int commaIndex = valueSpan.IndexOf(','); + ReadOnlySpan valueNameSpan = commaIndex < 0 ? valueSpan : valueSpan.Slice(0, commaIndex); +#if NET6_0_OR_GREATER + if(Enum.TryParse(valueNameSpan, true, out var result)) +#else + if(Enum.TryParse(valueNameSpan.ToString(), true, out var result)) +#endif + value |= (int)(object)result; + valueSpan = commaIndex < 0 ? ReadOnlySpan.Empty : valueSpan.Slice(commaIndex + 1); + } + return (T)(object)value; + } + else if(Enum.TryParse(rawValue, out var result)) + return result; + return null; + } +} diff --git a/src/serialization/form/FormParseNodeFactory.cs b/src/serialization/form/FormParseNodeFactory.cs new file mode 100644 index 00000000..9fa47638 --- /dev/null +++ b/src/serialization/form/FormParseNodeFactory.cs @@ -0,0 +1,44 @@ +using Microsoft.Kiota.Abstractions.Serialization; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Kiota.Serialization.Form; + +/// +/// The implementation for form content types +/// +public class FormParseNodeFactory : IAsyncParseNodeFactory +{ + /// + public string ValidContentType => "application/x-www-form-urlencoded"; + /// + public IParseNode GetRootParseNode(string contentType, Stream content) { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + if( content == null) + throw new ArgumentNullException(nameof(content)); + + using var reader = new StreamReader(content); + var rawValue = reader.ReadToEnd(); + return new FormParseNode(rawValue); + } + /// + public async Task GetRootParseNodeAsync(string contentType, Stream content, + CancellationToken cancellationToken = default) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + if(content == null) + throw new ArgumentNullException(nameof(content)); + + using var reader = new StreamReader(content); + var rawValue = await reader.ReadToEndAsync().ConfigureAwait(false); + return new FormParseNode(rawValue); + } +} diff --git a/src/serialization/form/FormSerializationWriter.cs b/src/serialization/form/FormSerializationWriter.cs new file mode 100644 index 00000000..a2ee0442 --- /dev/null +++ b/src/serialization/form/FormSerializationWriter.cs @@ -0,0 +1,275 @@ +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Xml; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.IO; +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using System; +using System.Collections.Generic; + +namespace Microsoft.Kiota.Serialization.Form; +/// Represents a serialization writer that can be used to write a form url encoded string. +public class FormSerializationWriter : ISerializationWriter +{ + private int depth; + private readonly StringBuilder _builder = new(); + /// + public Action? OnBeforeObjectSerialization { get; set; } + /// + public Action? OnAfterObjectSerialization { get; set; } + /// + public Action? OnStartObjectSerialization { get; set; } + /// + public void Dispose() { + GC.SuppressFinalize(this); + } + /// + public Stream GetSerializedContent() => new MemoryStream(Encoding.UTF8.GetBytes(_builder.ToString())); + /// + public void WriteAdditionalData(IDictionary value) { + if(value == null) return; + + foreach(var dataValue in value) + WriteAnyValue(dataValue.Key, dataValue.Value); + } + + private void WriteAnyValue(string? key, object value) + { + switch(value) + { + case null: + WriteNullValue(key); + break; + case decimal d: + WriteDecimalValue(key, d); + break; + case bool b: + WriteBoolValue(key, b); + break; + case byte b: + WriteByteValue(key, b); + break; + case sbyte b: + WriteSbyteValue(key, b); + break; + case int i: + WriteIntValue(key, i); + break; + case float f: + WriteFloatValue(key, f); + break; + case long l: + WriteLongValue(key, l); + break; + case double d: + WriteDoubleValue(key, d); + break; + case Guid g: + WriteGuidValue(key, g); + break; + case DateTimeOffset dto: + WriteDateTimeOffsetValue(key, dto); + break; + case TimeSpan timeSpan: + WriteTimeSpanValue(key, timeSpan); + break; + case Time time: + WriteTimeValue(key, time); + break; + case IEnumerable coll: + WriteCollectionOfPrimitiveValues(key, coll); + break; + case IParsable: + throw new InvalidOperationException("Form serialization does not support nested objects."); + default: + WriteStringValue(key,value.ToString());// works for Date and String types + break; + + } + } + + /// + public void WriteBoolValue(string? key, bool? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString().ToLowerInvariant()); + } + + /// + public void WriteByteArrayValue(string? key, byte[]? value) { + if(value != null)//empty array is meaningful + WriteStringValue(key, value.Length > 0 ? Convert.ToBase64String(value) : string.Empty); + } + + /// + public void WriteByteValue(string? key, byte? value) { + if(value.HasValue) + WriteIntValue(key, Convert.ToInt32(value.Value)); + } + + /// + public void WriteCollectionOfObjectValues(string? key, IEnumerable? values) where T : IParsable => throw new InvalidOperationException("Form serialization does not support collections."); + + /// + public void WriteCollectionOfPrimitiveValues(string? key, IEnumerable? values) + { + if (values == null) return; + foreach (var value in values) + { + if (value != null) + WriteAnyValue(key, value); + } + } + + /// + public void WriteDateTimeOffsetValue(string? key, DateTimeOffset? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString("o")); + } + /// + public void WriteDateValue(string? key, Date? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString()); + } + /// + public void WriteDecimalValue(string? key, decimal? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString(CultureInfo.InvariantCulture)); + } + /// + public void WriteDoubleValue(string? key, double? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString(CultureInfo.InvariantCulture)); + } + /// + public void WriteFloatValue(string? key, float? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString(CultureInfo.InvariantCulture)); + } + /// + public void WriteGuidValue(string? key, Guid? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString("D")); + } + /// + public void WriteIntValue(string? key, int? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString(CultureInfo.InvariantCulture)); + } + /// + public void WriteLongValue(string? key, long? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString(CultureInfo.InvariantCulture)); + } + /// + public void WriteNullValue(string? key) { + WriteStringValue(key, "null"); + } + /// + public void WriteObjectValue(string? key, T? value, params IParsable?[] additionalValuesToMerge) where T : IParsable + { + if(depth > 0) throw new InvalidOperationException("Form serialization does not support nested objects."); + depth++; + if (value == null && !Array.Exists(additionalValuesToMerge, static x => x is not null)) return; + + if(value != null) { + OnBeforeObjectSerialization?.Invoke(value); + OnStartObjectSerialization?.Invoke(value, this); + value.Serialize(this); + } + foreach (var additionalValueToMerge in additionalValuesToMerge) + { + if (additionalValueToMerge is null) continue; + + OnBeforeObjectSerialization?.Invoke(additionalValueToMerge); + OnStartObjectSerialization?.Invoke(additionalValueToMerge, this); + additionalValueToMerge.Serialize(this); + OnAfterObjectSerialization?.Invoke(additionalValueToMerge); + } + if(value != null) + OnAfterObjectSerialization?.Invoke(value); + } + /// + public void WriteSbyteValue(string? key, sbyte? value) { + if(value.HasValue) + WriteIntValue(key, Convert.ToInt32(value.Value)); + } + /// + public void WriteStringValue(string? key, string? value) { + if(string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) return; + if(_builder.Length > 0) _builder.Append('&'); + _builder.Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(value)); + } + /// + public void WriteTimeSpanValue(string? key, TimeSpan? value) { + if(value.HasValue) + WriteStringValue(key, XmlConvert.ToString(value.Value)); + } + /// + public void WriteTimeValue(string? key, Time? value) { + if(value.HasValue) + WriteStringValue(key, value.Value.ToString()); + } + /// +#if NET5_0_OR_GREATER + public void WriteCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, IEnumerable? values) where T : struct, Enum +#else + public void WriteCollectionOfEnumValues(string? key, IEnumerable? values) where T : struct, Enum +#endif + { + if(values == null) return; + + StringBuilder? valueNames = null; + foreach(var x in values) + { + if(x.HasValue && Enum.GetName(typeof(T), x.Value) is string valueName) + { + if(valueNames == null) + valueNames = new StringBuilder(); + else + valueNames.Append(","); + valueNames.Append(valueName.ToFirstCharacterLowerCase()); + } + } + + if(valueNames is not null) + WriteStringValue(key, valueNames.ToString()); + } + + /// +#if NET5_0_OR_GREATER + public void WriteEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, T? value) where T : struct, Enum +#else + public void WriteEnumValue(string? key, T? value) where T : struct, Enum +#endif + { + if(value.HasValue) + { + if (typeof(T).IsDefined(typeof(FlagsAttribute))) + { + T[] values = +#if NET5_0_OR_GREATER + Enum.GetValues(); +#else + (T[])Enum.GetValues(typeof(T)); +#endif + StringBuilder valueNames = new StringBuilder(); + foreach(var x in values) + { + if(value.Value.HasFlag(x) && Enum.GetName(typeof(T), x) is string valueName) + { + if(valueNames.Length > 0) + valueNames.Append(","); + valueNames.Append(valueName.ToFirstCharacterLowerCase()); + } + } + WriteStringValue(key, valueNames.ToString()); + } + else WriteStringValue(key, value.Value.ToString().ToFirstCharacterLowerCase()); + } + } +} diff --git a/src/serialization/form/FormSerializationWriterFactory.cs b/src/serialization/form/FormSerializationWriterFactory.cs new file mode 100644 index 00000000..46201f57 --- /dev/null +++ b/src/serialization/form/FormSerializationWriterFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.Kiota.Abstractions.Serialization; +using System; + +namespace Microsoft.Kiota.Serialization.Form; +/// Represents a serialization writer factory that can be used to create a form url encoded serialization writer. +public class FormSerializationWriterFactory : ISerializationWriterFactory +{ + /// + public string ValidContentType => "application/x-www-form-urlencoded"; + /// + public ISerializationWriter GetSerializationWriter(string contentType) { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + return new FormSerializationWriter(); + } +} diff --git a/src/serialization/form/Microsoft.Kiota.Serialization.Form.csproj b/src/serialization/form/Microsoft.Kiota.Serialization.Form.csproj new file mode 100644 index 00000000..554f9392 --- /dev/null +++ b/src/serialization/form/Microsoft.Kiota.Serialization.Form.csproj @@ -0,0 +1,16 @@ + + + + + Kiota URI form encoded serialization provider. + Kiota URI form encoded serialization provider. + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + + + + + + + diff --git a/src/serialization/form/README.md b/src/serialization/form/README.md new file mode 100644 index 00000000..c5946fdb --- /dev/null +++ b/src/serialization/form/README.md @@ -0,0 +1,39 @@ +# Kiota URI Form Encoded Serialization Library for dotnet + +The Form Serialization Library for dotnet is the dotnet `application/x-www-form-urlencoded` serialization library implementation. + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a form serialization package to handle `application/x-www-form-urlencoded` payloads from a supporting API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Kiota Json Serialization Library + +```shell +dotnet add package Microsoft.Kiota.Serialization.Form +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Serialization.Form.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/serialization/json/Changelog-old.md b/src/serialization/json/Changelog-old.md new file mode 100644 index 00000000..6d76bed1 --- /dev/null +++ b/src/serialization/json/Changelog-old.md @@ -0,0 +1,234 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.3.3] - 2024-05-24 + +### Changed + +- Remove all LINQ usage from repo (except tests) + +## [1.3.2] - 2024-05-23 + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [microsoft/kiota-http-dotnet#256](https://github.com/microsoft/kiota-http-dotnet/issues/258) + +## [1.3.1] - 2024-05-20 + +### Changed + +- Updated serialization and deserialization of enums to remove LINQ to resolve NativeAOT compatibility issue + +## [1.3.0] - 2024-05-13 + +### Added + +- Implements IAsyncParseNodeFactory interface which adds async support + +## [1.2.3] - 2024-04-25 + +### Changed + +- Parse empty strings as nullable Guid + +## [1.2.2] - 2024-04-19 + +### Changed + +- Replaced the included license by license expression for better auditing capabilities. + +## [1.2.1] - 2024-04-17 + +### Changed + +- Have made System.Text.Json only be included on Net Standard's TFM & net 5 + +## [1.2.0] - 2024-03-22 + +### Added + +- Added support for untyped nodes. () + +## [1.1.8] - 2024-02-27 + +- Reduced `DynamicallyAccessedMembers` scope for enum methods to prevent ILC warnings. + +## [1.1.7] - 2024-02-26 + +- Add ability to use `JsonSerializerContext` (and `JsonSerialzerOptions`) when serializing and deserializing + +## [1.1.6] - 2024-02-23 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.1.5] - 2024-02-05 + +### Changed + +- Fixes `IsTrimmable` property on the project. + +## [1.1.4] - 2024-01-30 + +### Changed + +- Fixed AOT warnings caused by reflection on enum types. + +## [1.1.3] - 2024-01-29 + +### Changed + +- Fixed a bug where serialization of decimal values would write them as empty objects. + +### Added + +## [1.1.2] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.1.1] - 2023-10-23 + +### Changed + +- Fixed a bug where deserialization of downcast type fields would be ignored. + +## [1.1.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +## [1.0.8] - 2023-07-14 + +### Changed + +- Fixes deserialization of arrays with item type long + +## [1.0.7] - 2023-06-28 + +### Changed + +- Fixed composed types serialization. + +## [1.0.6] - 2023-05-19 + +### Changed + +- #86: Fix inheritance new keyword for hiding existing implementation of deserializing method +- #85: Bump Microsoft.NET.Test.Sdk from 17.5.0 to 17.6.0 +- #84: Bump Microsoft.TestPlatform.ObjectModel from 17.5.0 to 17.6.0 +- #82: Bump dependabot/fetch-metadata from 1.3.6 to 1.4.0 +- #81: Bump Microsoft.Kiota.Abstractions from 1.1.0 to 1.1.1 + +## [1.0.5] - 2023-05-17 + +### Changed + +- Fixes a bug where 'new' keyword on derived classes from IParsable is not being respected, returning null properties for json parsed nodes + +### Added + +## [1.0.5] - 2023-04-04 + +### Changed + +- Fixes a bug where EnumMember attribute enums would have the first letter lowecased + +## [1.0.4] - 2023-04-03 + +### Changed + +- Fixes a bug where EnumMember attribute was not taken into account during serialization/deserialization + +## [1.0.3] - 2023-03-15 + +### Changed + +- Fixes serialization of DateTime type in the additionalData + +## [1.0.2] - 2023-03-10 + +### Changed + +- Bumps abstraction dependency + +## [1.0.1] - 2023-03-08 + +### Changed + +- Update minimum version of [`System.Text.Json`](https://www.nuget.org/packages/System.Text.Json) to `6.0.0`. + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +## [1.0.0-rc.3] - 2023-01-27 + +### Changed + +- Relaxed nullability tolerance when merging objects for composed types. + +## [1.0.0-rc.2] - 2023-01-17 + +### Changed + +- Adds support for nullable reference types + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +## [1.0.0-preview.7] - 2022-09-02 + +### Added + +- Added support for composed types serialization. + +## [1.0.0-preview.6] - 2022-05-27 + +### Changed + +- Fixes a bug where JsonParseNode.GetChildNode would throw an exception if the property name did not exist in the json. + +## [1.0.0-preview.5] - 2022-05-18 + +### Changed + +- Updated abstractions version to 1.0.0.preview8 + +## [1.0.0-preview.4] - 2022-04-12 + +### Changed + +- Breaking: Changes target runtime to netstandard2.0 + +## [1.0.0-preview.3] - 2022-04-11 + +### Changed + +- Fixes handling of JsonElement types in additionData during serialization + +## [1.0.0-preview.2] - 2022-04-04 + +### Changed + +- Breaking: simplifies the field deserializers. + +## [1.0.0-preview.1] - 2022-03-18 + +### Added + +- Initial Nuget release diff --git a/src/serialization/json/JsonParseNode.cs b/src/serialization/json/JsonParseNode.cs new file mode 100644 index 00000000..5fa6d114 --- /dev/null +++ b/src/serialization/json/JsonParseNode.cs @@ -0,0 +1,594 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Xml; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Extensions; + +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Json +{ + /// + /// The implementation for the json content type + /// + public class JsonParseNode : IParseNode + { + private readonly JsonElement _jsonNode; + private readonly KiotaJsonSerializationContext _jsonSerializerContext; + + /// + /// The constructor. + /// + /// The JsonElement to initialize the node with + public JsonParseNode(JsonElement node) + : this(node, KiotaJsonSerializationContext.Default) + { + } + + /// + /// The constructor. + /// + /// The JsonElement to initialize the node with + /// The JsonSerializerContext to utilize. + public JsonParseNode(JsonElement node, KiotaJsonSerializationContext jsonSerializerContext) + { + _jsonNode = node; + _jsonSerializerContext = jsonSerializerContext; + } + + /// + /// Get the string value from the json node + /// + /// A string value + public string? GetStringValue() => _jsonNode.ValueKind == JsonValueKind.String + ? _jsonNode.Deserialize(_jsonSerializerContext.String) + : null; + + /// + /// Get the boolean value from the json node + /// + /// A boolean value + public bool? GetBoolValue() => + _jsonNode.ValueKind == JsonValueKind.True || _jsonNode.ValueKind == JsonValueKind.False + ? _jsonNode.Deserialize(_jsonSerializerContext.Boolean) + : null; + + /// + /// Get the byte value from the json node + /// + /// A byte value + public byte? GetByteValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Byte) + : null; + + /// + /// Get the sbyte value from the json node + /// + /// A sbyte value + public sbyte? GetSbyteValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.SByte) + : null; + + /// + /// Get the int value from the json node + /// + /// A int value + public int? GetIntValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Int32) + : null; + + /// + /// Get the float value from the json node + /// + /// A float value + public float? GetFloatValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Single) + : null; + + /// + /// Get the Long value from the json node + /// + /// A Long value + public long? GetLongValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Int64) + : null; + + /// + /// Get the double value from the json node + /// + /// A double value + public double? GetDoubleValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Double) + : null; + + /// + /// Get the decimal value from the json node + /// + /// A decimal value + public decimal? GetDecimalValue() => _jsonNode.ValueKind == JsonValueKind.Number + ? _jsonNode.Deserialize(_jsonSerializerContext.Decimal) + : null; + + /// + /// Get the guid value from the json node + /// + /// A guid value + public Guid? GetGuidValue() + { + if(_jsonNode.ValueKind != JsonValueKind.String) + return null; + + if(_jsonNode.TryGetGuid(out var guid)) + return guid; + + if(string.IsNullOrEmpty(_jsonNode.GetString())) + return null; + + return _jsonNode.Deserialize(_jsonSerializerContext.Guid); + } + + /// + /// Get the value from the json node + /// + /// A value + public DateTimeOffset? GetDateTimeOffsetValue() + { + if(_jsonNode.ValueKind != JsonValueKind.String) + return null; + + if(_jsonNode.TryGetDateTimeOffset(out var dateTimeOffset)) + return dateTimeOffset; + + var dateTimeOffsetStr = _jsonNode.GetString(); + if(string.IsNullOrEmpty(dateTimeOffsetStr)) + return null; + + if (DateTimeOffset.TryParse(dateTimeOffsetStr, out dateTimeOffset)) + return dateTimeOffset; + + return _jsonNode.Deserialize(_jsonSerializerContext.DateTimeOffset); + } + + /// + /// Get the value from the json node + /// + /// A value + public TimeSpan? GetTimeSpanValue() + { + var jsonString = _jsonNode.GetString(); + if(string.IsNullOrEmpty(jsonString)) + return null; + + // Parse an ISO8601 duration.http://en.wikipedia.org/wiki/ISO_8601#Durations to a TimeSpan + return XmlConvert.ToTimeSpan(jsonString); + } + + /// + /// Get the value from the json node + /// + /// A value + public Date? GetDateValue() + { + var dateString = _jsonNode.GetString(); + if(string.IsNullOrEmpty(dateString)) + return null; + + if(DateTime.TryParse(dateString, out var result)) + return new Date(result); + + return _jsonNode.Deserialize(_jsonSerializerContext.Date); + } + + /// + /// Get the value from the json node + /// + /// A value + public Time? GetTimeValue() + { + var dateString = _jsonNode.GetString(); + if(string.IsNullOrEmpty(dateString)) + return null; + + if(DateTime.TryParse(dateString, out var result)) + return new Time(result); + + return _jsonNode.Deserialize(_jsonSerializerContext.Time); + } + + /// + /// Get the enumeration value of type from the json node + /// + /// An enumeration value or null +#if NET5_0_OR_GREATER + public T? GetEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() where T : struct, Enum +#else + public T? GetEnumValue() where T : struct, Enum +#endif + { + var rawValue = _jsonNode.GetString(); + if(string.IsNullOrEmpty(rawValue)) return null; + + rawValue = ToEnumRawName(rawValue!); + if (typeof(T).IsDefined(typeof(FlagsAttribute))) + { + ReadOnlySpan valueSpan = rawValue.AsSpan(); + int value = 0; + while(valueSpan.Length > 0) + { + int commaIndex = valueSpan.IndexOf(','); + ReadOnlySpan valueNameSpan = commaIndex < 0 ? valueSpan : valueSpan.Slice(0, commaIndex); +#if NET6_0_OR_GREATER + if(Enum.TryParse(valueNameSpan, true, out var result)) +#else + if(Enum.TryParse(valueNameSpan.ToString(), true, out var result)) +#endif + value |= (int)(object)result; + valueSpan = commaIndex < 0 ? ReadOnlySpan.Empty : valueSpan.Slice(commaIndex + 1); + } + return (T)(object)value; + } + else + return Enum.TryParse(rawValue, true, out var result) ? result : null; + } + + /// + /// Get the collection of type from the json node + /// + /// The factory to use to create the model object. + /// A collection of objects + public IEnumerable GetCollectionOfObjectValues(ParsableFactory factory) where T : IParsable + { + if (_jsonNode.ValueKind == JsonValueKind.Array) { + var enumerator = _jsonNode.EnumerateArray(); + while(enumerator.MoveNext()) + { + var currentParseNode = new JsonParseNode(enumerator.Current, _jsonSerializerContext) + { + OnAfterAssignFieldValues = OnAfterAssignFieldValues, + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues + }; + yield return currentParseNode.GetObjectValue(factory); + } + } + } + /// + /// Gets the collection of enum values of the node. + /// + /// The collection of enum values. +#if NET5_0_OR_GREATER + public IEnumerable GetCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() where T : struct, Enum +#else + public IEnumerable GetCollectionOfEnumValues() where T : struct, Enum +#endif + { + if (_jsonNode.ValueKind == JsonValueKind.Array) { + var enumerator = _jsonNode.EnumerateArray(); + while(enumerator.MoveNext()) + { + var currentParseNode = new JsonParseNode(enumerator.Current, _jsonSerializerContext) + { + OnAfterAssignFieldValues = OnAfterAssignFieldValues, + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues + }; + yield return currentParseNode.GetEnumValue(); + } + } + } + /// + /// Gets the byte array value of the node. + /// + /// The byte array value of the node. + public byte[]? GetByteArrayValue() { + var rawValue = _jsonNode.GetString(); + if(string.IsNullOrEmpty(rawValue)) + return null; + return Convert.FromBase64String(rawValue); + } + /// + /// Gets the untyped value of the node + /// + /// The untyped value of the node. + private UntypedNode? GetUntypedValue() => GetUntypedValue(_jsonNode); + + + /// + /// Get the collection of primitives of type from the json node + /// + /// A collection of objects + public IEnumerable GetCollectionOfPrimitiveValues() + { + if (_jsonNode.ValueKind == JsonValueKind.Array) { + var genericType = typeof(T); + foreach(var collectionValue in _jsonNode.EnumerateArray()) + { + var currentParseNode = new JsonParseNode(collectionValue, _jsonSerializerContext) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }; + if(genericType == TypeConstants.BooleanType) + yield return (T)(object)currentParseNode.GetBoolValue()!; + else if(genericType == TypeConstants.ByteType) + yield return (T)(object)currentParseNode.GetByteValue()!; + else if(genericType == TypeConstants.SbyteType) + yield return (T)(object)currentParseNode.GetSbyteValue()!; + else if(genericType == TypeConstants.StringType) + yield return (T)(object)currentParseNode.GetStringValue()!; + else if(genericType == TypeConstants.IntType) + yield return (T)(object)currentParseNode.GetIntValue()!; + else if(genericType == TypeConstants.FloatType) + yield return (T)(object)currentParseNode.GetFloatValue()!; + else if(genericType == TypeConstants.LongType) + yield return (T)(object)currentParseNode.GetLongValue()!; + else if(genericType == TypeConstants.DoubleType) + yield return (T)(object)currentParseNode.GetDoubleValue()!; + else if(genericType == TypeConstants.GuidType) + yield return (T)(object)currentParseNode.GetGuidValue()!; + else if(genericType == TypeConstants.DateTimeOffsetType) + yield return (T)(object)currentParseNode.GetDateTimeOffsetValue()!; + else if(genericType == TypeConstants.TimeSpanType) + yield return (T)(object)currentParseNode.GetTimeSpanValue()!; + else if(genericType == TypeConstants.DateType) + yield return (T)(object)currentParseNode.GetDateValue()!; + else if(genericType == TypeConstants.TimeType) + yield return (T)(object)currentParseNode.GetTimeValue()!; + else + throw new InvalidOperationException($"unknown type for deserialization {genericType.FullName}"); + } + } + } + + /// + /// Gets the collection of untyped values of the node. + /// + /// The collection of untyped values. + private IEnumerable GetCollectionOfUntypedValues(JsonElement jsonNode) + { + if (jsonNode.ValueKind == JsonValueKind.Array) + { + foreach(var collectionValue in jsonNode.EnumerateArray()) + { + var currentParseNode = new JsonParseNode(collectionValue) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }; + yield return currentParseNode.GetUntypedValue()!; + } + } + } + + /// + /// Gets the collection of properties in the untyped object. + /// + /// The collection of properties in the untyped object. + private IDictionary GetPropertiesOfUntypedObject(JsonElement jsonNode) + { + var properties = new Dictionary(); + if(jsonNode.ValueKind == JsonValueKind.Object) + { + foreach(var objectValue in jsonNode.EnumerateObject()) + { + JsonElement property = objectValue.Value; + if(objectValue.Value.ValueKind == JsonValueKind.Object) + { + var childNode = new JsonParseNode(objectValue.Value) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }; + var objectVal = childNode.GetPropertiesOfUntypedObject(childNode._jsonNode); + properties[objectValue.Name] = new UntypedObject(objectVal); + } + else + { + properties[objectValue.Name] = GetUntypedValue(property)!; + } + } + } + return properties; + } + + private UntypedNode? GetUntypedValue(JsonElement jsonNode) + { + UntypedNode? untypedNode = null; + switch(jsonNode.ValueKind) + { + case JsonValueKind.Number: + if(jsonNode.TryGetInt32(out var intValue)) + { + untypedNode = new UntypedInteger(intValue); + } + else if(jsonNode.TryGetInt64(out var longValue)) + { + untypedNode = new UntypedLong(longValue); + } + else if(jsonNode.TryGetDecimal(out var decimalValue)) + { + untypedNode = new UntypedDecimal(decimalValue); + } + else if(jsonNode.TryGetSingle(out var floatValue)) + { + untypedNode = new UntypedFloat(floatValue); + } + else if(jsonNode.TryGetDouble(out var doubleValue)) + { + untypedNode = new UntypedDouble(doubleValue); + } + else throw new InvalidOperationException("unexpected additional value type during number deserialization"); + break; + case JsonValueKind.String: + var stringValue = jsonNode.GetString(); + untypedNode = new UntypedString(stringValue); + break; + case JsonValueKind.True: + case JsonValueKind.False: + var boolValue = jsonNode.GetBoolean(); + untypedNode = new UntypedBoolean(boolValue); + break; + case JsonValueKind.Array: + var arrayValue = GetCollectionOfUntypedValues(jsonNode); + untypedNode = new UntypedArray(arrayValue); + break; + case JsonValueKind.Object: + var objectValue = GetPropertiesOfUntypedObject(jsonNode); + untypedNode = new UntypedObject(objectValue); + break; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + untypedNode = new UntypedNull(); + break; + } + + return untypedNode; + } + + /// + /// The action to perform before assigning field values. + /// + public Action? OnBeforeAssignFieldValues { get; set; } + + /// + /// The action to perform after assigning field values. + /// + public Action? OnAfterAssignFieldValues { get; set; } + + /// + /// Get the object of type from the json node + /// + /// The factory to use to create the model object. + /// A object of the specified type + public T GetObjectValue(ParsableFactory factory) where T : IParsable + { + // until interface exposes GetUntypedValue() + var genericType = typeof(T); + if(genericType == typeof(UntypedNode)) + { + return (T)(object)GetUntypedValue()!; + } + var item = factory(this); + OnBeforeAssignFieldValues?.Invoke(item); + AssignFieldValues(item); + OnAfterAssignFieldValues?.Invoke(item); + return item; + } + private void AssignFieldValues(T item) where T : IParsable + { + if(_jsonNode.ValueKind != JsonValueKind.Object) return; + IDictionary? itemAdditionalData = null; + if(item is IAdditionalDataHolder holder) + { + holder.AdditionalData ??= new Dictionary(); + itemAdditionalData = holder.AdditionalData; + } + var fieldDeserializers = item.GetFieldDeserializers(); + + foreach(var fieldValue in _jsonNode.EnumerateObject()) + { + if(fieldDeserializers.ContainsKey(fieldValue.Name)) + { + if(fieldValue.Value.ValueKind == JsonValueKind.Null) + continue;// If the property is already null just continue. As calling functions like GetDouble,GetBoolValue do not process JsonValueKind.Null. + + var fieldDeserializer = fieldDeserializers[fieldValue.Name]; + Debug.WriteLine($"found property {fieldValue.Name} to deserialize"); + fieldDeserializer.Invoke(new JsonParseNode(fieldValue.Value, _jsonSerializerContext) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }); + } + else if (itemAdditionalData != null) + { + Debug.WriteLine($"found additional property {fieldValue.Name} to deserialize"); + IDictionaryExtensions.TryAdd(itemAdditionalData, fieldValue.Name, TryGetAnything(fieldValue.Value)!); + } + else + { + Debug.WriteLine($"found additional property {fieldValue.Name} to deserialize but the model doesn't support additional data"); + } + } + } + private object? TryGetAnything(JsonElement element) + { + switch(element.ValueKind) + { + case JsonValueKind.Number: + if(element.TryGetDecimal(out var dec)) return dec; + else if(element.TryGetDouble(out var db)) return db; + else if(element.TryGetInt16(out var s)) return s; + else if(element.TryGetInt32(out var i)) return i; + else if(element.TryGetInt64(out var l)) return l; + else if(element.TryGetSingle(out var f)) return f; + else if(element.TryGetUInt16(out var us)) return us; + else if(element.TryGetUInt32(out var ui)) return ui; + else if(element.TryGetUInt64(out var ul)) return ul; + else throw new InvalidOperationException("unexpected additional value type during number deserialization"); + case JsonValueKind.String: + if(element.TryGetDateTime(out var dt)) return dt; + else if(element.TryGetDateTimeOffset(out var dto)) return dto; + else if(element.TryGetGuid(out var g)) return g; + else return element.GetString(); + case JsonValueKind.Array: + case JsonValueKind.Object: + return GetUntypedValue(element); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return null; + default: + throw new InvalidOperationException($"unexpected additional value type during deserialization json kind : {element.ValueKind}"); + } + } + + /// + /// Get the child node of the specified identifier + /// + /// The identifier of the child node + /// An instance of + public IParseNode? GetChildNode(string identifier) + { + if(_jsonNode.ValueKind == JsonValueKind.Object && _jsonNode.TryGetProperty(identifier ?? throw new ArgumentNullException(nameof(identifier)), out var jsonElement)) + { + return new JsonParseNode(jsonElement, _jsonSerializerContext) + { + OnBeforeAssignFieldValues = OnBeforeAssignFieldValues, + OnAfterAssignFieldValues = OnAfterAssignFieldValues + }; + } + + return default; + } + +#if NET5_0_OR_GREATER + private static string ToEnumRawName<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string value) where T : struct, Enum +#else + private static string ToEnumRawName(string value) where T : struct, Enum +#endif + { + foreach (var field in typeof(T).GetFields()) + { + if (field.GetCustomAttribute() is {} attr && value.Equals(attr.Value, StringComparison.Ordinal)) + { + return field.Name; + } + } + + return value; + } + } +} diff --git a/src/serialization/json/JsonParseNodeFactory.cs b/src/serialization/json/JsonParseNodeFactory.cs new file mode 100644 index 00000000..561d706f --- /dev/null +++ b/src/serialization/json/JsonParseNodeFactory.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json +{ + /// + /// The implementation for json content types + /// + public class JsonParseNodeFactory : IAsyncParseNodeFactory + { + private readonly KiotaJsonSerializationContext _jsonJsonSerializationContext; + + /// + /// The constructor. + /// + public JsonParseNodeFactory() + : this(KiotaJsonSerializationContext.Default) + { + } + + /// + /// The constructor. + /// + /// The KiotaSerializationContext to utilize. + public JsonParseNodeFactory(KiotaJsonSerializationContext jsonJsonSerializationContext) + { + _jsonJsonSerializationContext = jsonJsonSerializationContext; + } + + /// + /// The valid content type for json + /// + public string ValidContentType { get; } = "application/json"; + + /// + /// Gets the root of the json to be read. + /// + /// The content type of the stream to be parsed + /// The containing json to parse. + /// An instance of for json manipulation + [Obsolete("Use GetRootParseNodeAsync instead")] + public IParseNode GetRootParseNode(string contentType, Stream content) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + _ = content ?? throw new ArgumentNullException(nameof(content)); + + using var jsonDocument = JsonDocument.Parse(content); + return new JsonParseNode(jsonDocument.RootElement.Clone(), _jsonJsonSerializationContext); + } + /// + /// Asynchronously gets the root of the json to be read. + /// + /// The content type of the stream to be parsed + /// The containing json to parse. + /// The cancellation token for the task + /// An instance of for json manipulation + public async Task GetRootParseNodeAsync(string contentType, Stream content, + CancellationToken cancellationToken = default) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + _ = content ?? throw new ArgumentNullException(nameof(content)); + + using var jsonDocument = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken).ConfigureAwait(false); + return new JsonParseNode(jsonDocument.RootElement.Clone(), _jsonJsonSerializationContext); + } + } +} diff --git a/src/serialization/json/JsonSerializationWriter.cs b/src/serialization/json/JsonSerializationWriter.cs new file mode 100644 index 00000000..7ae528ec --- /dev/null +++ b/src/serialization/json/JsonSerializationWriter.cs @@ -0,0 +1,620 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Serialization; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using System.Xml; +using System.Text; + +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Json +{ + /// + /// The implementation for json content types. + /// + public class JsonSerializationWriter : ISerializationWriter, IDisposable + { + private readonly MemoryStream _stream = new MemoryStream(); + private readonly KiotaJsonSerializationContext _kiotaJsonSerializationContext; + + /// + /// The instance for writing json content + /// + public readonly Utf8JsonWriter writer; + + /// + /// The constructor + /// + public JsonSerializationWriter() + : this(KiotaJsonSerializationContext.Default) + { + } + + /// + /// The constructor + /// + /// The KiotaJsonSerializationContext to use. + public JsonSerializationWriter(KiotaJsonSerializationContext kiotaJsonSerializationContext) + { + _kiotaJsonSerializationContext = kiotaJsonSerializationContext; + writer = new Utf8JsonWriter(_stream); + } + + /// + /// The action to perform before object serialization + /// + public Action? OnBeforeObjectSerialization { get; set; } + + /// + /// The action to perform after object serialization + /// + public Action? OnAfterObjectSerialization { get; set; } + + /// + /// The action to perform on the start of object serialization + /// + public Action? OnStartObjectSerialization { get; set; } + + /// + /// Get the stream of the serialized content + /// + /// The of the serialized content + public Stream GetSerializedContent() + { + writer.Flush(); + _stream.Position = 0; + return _stream; + } + + /// + /// Write the string value + /// + /// The key of the json node + /// The string value + public void WriteStringValue(string? key, string? value) + { + if(value != null) + { + // we want to keep empty string because they are meaningful + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + JsonSerializer.Serialize(writer, value, TypeConstants.StringType, _kiotaJsonSerializationContext); + } + } + + /// + /// Write the boolean value + /// + /// The key of the json node + /// The boolean value + public void WriteBoolValue(string? key, bool? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.BooleanType, _kiotaJsonSerializationContext); + } + + /// + /// Write the byte value + /// + /// The key of the json node + /// The byte value + public void WriteByteValue(string? key, byte? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.ByteType, _kiotaJsonSerializationContext); + } + + /// + /// Write the sbyte value + /// + /// The key of the json node + /// The sbyte value + public void WriteSbyteValue(string? key, sbyte? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.SbyteType, _kiotaJsonSerializationContext); + } + + /// + /// Write the int value + /// + /// The key of the json node + /// The int value + public void WriteIntValue(string? key, int? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value, TypeConstants.IntType, _kiotaJsonSerializationContext); + } + + /// + /// Write the float value + /// + /// The key of the json node + /// The float value + public void WriteFloatValue(string? key, float? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.FloatType, _kiotaJsonSerializationContext); + } + + /// + /// Write the long value + /// + /// The key of the json node + /// The long value + public void WriteLongValue(string? key, long? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.LongType, _kiotaJsonSerializationContext); + } + + /// + /// Write the double value + /// + /// The key of the json node + /// The double value + public void WriteDoubleValue(string? key, double? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.DoubleType, _kiotaJsonSerializationContext); + + } + + /// + /// Write the decimal value + /// + /// The key of the json node + /// The decimal value + public void WriteDecimalValue(string? key, decimal? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.DecimalType, _kiotaJsonSerializationContext); + + } + + /// + /// Write the Guid value + /// + /// The key of the json node + /// The Guid value + public void WriteGuidValue(string? key, Guid? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.GuidType, _kiotaJsonSerializationContext); + } + + /// + /// Write the DateTimeOffset value + /// + /// The key of the json node + /// The DateTimeOffset value + public void WriteDateTimeOffsetValue(string? key, DateTimeOffset? value) + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + JsonSerializer.Serialize(writer, value.Value, TypeConstants.DateTimeOffsetType, _kiotaJsonSerializationContext); + } + + /// + /// Write the TimeSpan(An ISO8601 duration.For example, PT1M is "period time of 1 minute") value. + /// + /// The key of the json node + /// The TimeSpan value + public void WriteTimeSpanValue(string? key, TimeSpan? value) + { + if(value.HasValue) + WriteStringValue(key, XmlConvert.ToString(value.Value)); + } + + /// + /// Write the Date value + /// + /// The key of the json node + /// The Date value + public void WriteDateValue(string? key, Date? value) + => WriteStringValue(key, value?.ToString()); + + /// + /// Write the Time value + /// + /// The key of the json node + /// The Time value + public void WriteTimeValue(string? key, Time? value) + => WriteStringValue(key, value?.ToString()); + + /// + /// Write the null value + /// + /// The key of the json node + public void WriteNullValue(string? key) + { + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + writer.WriteNullValue(); + } + + /// + /// Write the enumeration value of type + /// + /// The key of the json node + /// The enumeration value +#if NET5_0_OR_GREATER + public void WriteEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, T? value) where T : struct, Enum +#else + public void WriteEnumValue(string? key, T? value) where T : struct, Enum +#endif + { + if(!string.IsNullOrEmpty(key) && value.HasValue) + writer.WritePropertyName(key!); + if(value.HasValue) + { + if (typeof(T).IsDefined(typeof(FlagsAttribute))) + { + var values = +#if NET5_0_OR_GREATER + Enum.GetValues(); +#else + (T[])Enum.GetValues(typeof(T)); +#endif + StringBuilder valueNames = new StringBuilder(); + foreach (var x in values) + { + if(value.Value.HasFlag(x) && GetEnumName(x) is string valueName) + { + if (valueNames.Length > 0) + valueNames.Append(","); + valueNames.Append(valueName); + } + } + WriteStringValue(null, valueNames.ToString()); + } + else WriteStringValue(null, GetEnumName(value.Value)); + } + } + + /// + /// Write the collection of primitives of type + /// + /// The key of the json node + /// The primitive collection + public void WriteCollectionOfPrimitiveValues(string? key, IEnumerable? values) + { + if(values != null) + { //empty array is meaningful + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + writer.WriteStartArray(); + foreach(var collectionValue in values) + WriteAnyValue(null, collectionValue); + writer.WriteEndArray(); + } + } + + /// + /// Write the collection of objects of type + /// + /// The key of the json node + /// The object collection + public void WriteCollectionOfObjectValues(string? key, IEnumerable? values) where T : IParsable + { + if(values != null) + { + // empty array is meaningful + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + writer.WriteStartArray(); + foreach(var item in values) + WriteObjectValue(null, item); + writer.WriteEndArray(); + } + } + /// + /// Writes the specified collection of enum values to the stream with an optional given key. + /// + /// The key to be used for the written value. May be null. + /// The enum values to be written. +#if NET5_0_OR_GREATER + public void WriteCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)]T>(string? key, IEnumerable? values) where T : struct, Enum +#else + public void WriteCollectionOfEnumValues(string? key, IEnumerable? values) where T : struct, Enum +#endif + { + if(values != null) + { //empty array is meaningful + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + writer.WriteStartArray(); + foreach(var item in values) + WriteEnumValue(null, item); + writer.WriteEndArray(); + } + } + /// + /// Writes the specified byte array as a base64 string to the stream with an optional given key. + /// + /// The key to be used for the written value. May be null. + /// The byte array to be written. + public void WriteByteArrayValue(string? key, byte[]? value) + { + if(value != null)//empty array is meaningful + WriteStringValue(key, value.Length > 0 ? Convert.ToBase64String(value) : string.Empty); + } + + /// + /// Write the object of type + /// + /// The key of the json node + /// The object instance to write + /// The additional values to merge to the main value when serializing an intersection wrapper. + public void WriteObjectValue(string? key, T? value, params IParsable?[] additionalValuesToMerge) where T : IParsable + { + var filteredAdditionalValuesToMerge = (IParsable[])Array.FindAll(additionalValuesToMerge, static x => x is not null); + if(value != null || filteredAdditionalValuesToMerge.Length > 0) + { + // until interface exposes WriteUntypedValue() + var serializingUntypedNode = value is UntypedNode; + if(!serializingUntypedNode && !string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + if(value != null) + OnBeforeObjectSerialization?.Invoke(value); + + if(serializingUntypedNode) + { + var untypedNode = value as UntypedNode; + OnStartObjectSerialization?.Invoke(untypedNode!, this); + WriteUntypedValue(key, untypedNode); + OnAfterObjectSerialization?.Invoke(untypedNode!); + } + else + { + var serializingScalarValue = value is IComposedTypeWrapper; + if(!serializingScalarValue) + writer.WriteStartObject(); + if(value != null) + { + OnStartObjectSerialization?.Invoke(value, this); + value.Serialize(this); + } + foreach(var additionalValueToMerge in filteredAdditionalValuesToMerge) + { + OnBeforeObjectSerialization?.Invoke(additionalValueToMerge!); + OnStartObjectSerialization?.Invoke(additionalValueToMerge!, this); + additionalValueToMerge!.Serialize(this); + OnAfterObjectSerialization?.Invoke(additionalValueToMerge); + } + if(!serializingScalarValue) + writer.WriteEndObject(); + } + if(value != null) OnAfterObjectSerialization?.Invoke(value); + } + } + + /// + /// Write the additional data property bag + /// + /// The additional data dictionary + public void WriteAdditionalData(IDictionary value) + { + if(value == null) + return; + + foreach(var dataValue in value) + WriteAnyValue(dataValue.Key, dataValue.Value); + } + + private void WriteNonParsableObjectValue(string? key, T value) + { + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + writer.WriteStartObject(); + if(value == null) + writer.WriteNullValue(); + else + foreach(var oProp in value.GetType().GetProperties()) + WriteAnyValue(oProp.Name, oProp.GetValue(value)); + writer.WriteEndObject(); + } + + private void WriteAnyValue(string? key, T value) + { + switch(value) + { + case string s: + WriteStringValue(key, s); + break; + case bool b: + WriteBoolValue(key, b); + break; + case byte b: + WriteByteValue(key, b); + break; + case sbyte b: + WriteSbyteValue(key, b); + break; + case int i: + WriteIntValue(key, i); + break; + case float f: + WriteFloatValue(key, f); + break; + case long l: + WriteLongValue(key, l); + break; + case double d: + WriteDoubleValue(key, d); + break; + case decimal dec: + WriteDecimalValue(key, dec); + break; + case Guid g: + WriteGuidValue(key, g); + break; + case DateTimeOffset dto: + WriteDateTimeOffsetValue(key, dto); + break; + case TimeSpan timeSpan: + WriteTimeSpanValue(key, timeSpan); + break; + case IEnumerable coll: + WriteCollectionOfPrimitiveValues(key, coll); + break; + case UntypedNode node: + WriteUntypedValue(key, node); + break; + case IParsable parseable: + WriteObjectValue(key, parseable); + break; + case Date date: + WriteDateValue(key, date); + break; + case DateTime dateTime: + WriteDateTimeOffsetValue(key, new DateTimeOffset(dateTime)); + break; + case Time time: + WriteTimeValue(key, time); + break; + case JsonElement jsonElement: + if(!string.IsNullOrEmpty(key)) + writer.WritePropertyName(key!); + jsonElement.WriteTo(writer); + break; + case object o: + WriteNonParsableObjectValue(key, o); + break; + case null: + WriteNullValue(key); + break; + default: + throw new InvalidOperationException($"error serialization additional data value with key {key}, unknown type {value?.GetType()}"); + } + } + + /// + public void Dispose() + { + writer.Dispose(); + GC.SuppressFinalize(this); + } +#if NET5_0_OR_GREATER + private static string? GetEnumName<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(T value) where T : struct, Enum +#else + private static string? GetEnumName(T value) where T : struct, Enum +#endif + { + var type = typeof(T); + + if(Enum.GetName(type, value) is not { } name) + throw new ArgumentException($"Invalid Enum value {value} for enum of type {type}"); + + if(type.GetField(name)?.GetCustomAttribute() is { } attribute) + return attribute.Value; + + return name.ToFirstCharacterLowerCase(); + } + /// + /// Writes a untyped value for the specified key. + /// + /// The key to be used for the written value. May be null. + /// The untyped node. + private void WriteUntypedValue(string? key, UntypedNode? value) + { + switch(value) + { + case UntypedString untypedString: + WriteStringValue(key, untypedString.GetValue()); + break; + case UntypedBoolean untypedBoolean: + WriteBoolValue(key, untypedBoolean.GetValue()); + break; + case UntypedInteger untypedInteger: + WriteIntValue(key, untypedInteger.GetValue()); + break; + case UntypedLong untypedLong: + WriteLongValue(key, untypedLong.GetValue()); + break; + case UntypedDecimal untypedDecimal: + WriteDecimalValue(key, untypedDecimal.GetValue()); + break; + case UntypedFloat untypedFloat: + WriteFloatValue(key, untypedFloat.GetValue()); + break; + case UntypedDouble untypedDouble: + WriteDoubleValue(key, untypedDouble.GetValue()); + break; + case UntypedObject untypedObject: + WriteUntypedObject(key, untypedObject); + break; + case UntypedArray array: + WriteUntypedArray(key, array); + break; + case UntypedNull: + WriteNullValue(key); + break; + } + } + + /// + /// Write a untyped object for the specified key. + /// + /// The key to be used for the written value. May be null. + /// The untyped object. + private void WriteUntypedObject(string? key, UntypedObject? value) + { + if (value != null) + { + if(!string.IsNullOrEmpty(key)) writer.WritePropertyName(key!); + writer.WriteStartObject(); + foreach(var item in value.GetValue()) + WriteUntypedValue(item.Key, item.Value); + writer.WriteEndObject(); + } + } + + /// + /// Writes the specified collection of untyped values. + /// + /// The key to be used for the written value. May be null. + /// The collection of untyped values. + private void WriteUntypedArray(string? key, UntypedArray? array) + { + if (array != null) + { + if(!string.IsNullOrEmpty(key)) writer.WritePropertyName(key!); + writer.WriteStartArray(); + foreach(var item in array.GetValue()) + WriteUntypedValue(null, item); + writer.WriteEndArray(); + } + } + } +} diff --git a/src/serialization/json/JsonSerializationWriterFactory.cs b/src/serialization/json/JsonSerializationWriterFactory.cs new file mode 100644 index 00000000..ae5fdc24 --- /dev/null +++ b/src/serialization/json/JsonSerializationWriterFactory.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json +{ + /// + /// The implementation for the json content type + /// + public class JsonSerializationWriterFactory : ISerializationWriterFactory + { + private readonly KiotaJsonSerializationContext _kiotaJsonSerializationContext; + + /// + /// The constructor. + /// + public JsonSerializationWriterFactory() + : this(KiotaJsonSerializationContext.Default) + { + } + + /// + /// The constructor. + /// + /// The KiotaJsonSerializationContext to use. + public JsonSerializationWriterFactory(KiotaJsonSerializationContext kiotaJsonSerializationContext) + { + _kiotaJsonSerializationContext = kiotaJsonSerializationContext; + } + + /// + /// The valid content type for json + /// + public string ValidContentType { get; } = "application/json"; + + /// + /// Get a valid for the content type + /// + /// The content type to search for + /// A instance for json writing + public ISerializationWriter GetSerializationWriter(string contentType) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + return new JsonSerializationWriter(_kiotaJsonSerializationContext); + } + } +} diff --git a/src/serialization/json/KiotaJsonSerializationContext.cs b/src/serialization/json/KiotaJsonSerializationContext.cs new file mode 100644 index 00000000..c9121fd5 --- /dev/null +++ b/src/serialization/json/KiotaJsonSerializationContext.cs @@ -0,0 +1,37 @@ +using System; +using System.Text.Json.Serialization; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Serialization.Json; + +/// +/// Json serialization context for Kiota. +/// +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(bool?))] +[JsonSerializable(typeof(byte))] +[JsonSerializable(typeof(byte?))] +[JsonSerializable(typeof(sbyte))] +[JsonSerializable(typeof(sbyte?))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(int?))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(float?))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(long?))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(double?))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(decimal?))] +[JsonSerializable(typeof(Guid))] +[JsonSerializable(typeof(Guid?))] +[JsonSerializable(typeof(DateTimeOffset))] +[JsonSerializable(typeof(DateTimeOffset?))] +[JsonSerializable(typeof(TimeSpan))] +[JsonSerializable(typeof(TimeSpan?))] +[JsonSerializable(typeof(Date))] +[JsonSerializable(typeof(Date?))] +[JsonSerializable(typeof(Time))] +[JsonSerializable(typeof(Time?))] +public partial class KiotaJsonSerializationContext : JsonSerializerContext; diff --git a/src/serialization/json/Microsoft.Kiota.Serialization.Json.csproj b/src/serialization/json/Microsoft.Kiota.Serialization.Json.csproj new file mode 100644 index 00000000..0a0cd3f6 --- /dev/null +++ b/src/serialization/json/Microsoft.Kiota.Serialization.Json.csproj @@ -0,0 +1,21 @@ + + + + + Kiota JSON serialization provider implementation with System.Text.Json. + Kiota JSON Serialization Library for dotnet using System.Text.Json + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + $(NoWarn);CS1591 + + + + + + + + + + + diff --git a/src/serialization/json/README.md b/src/serialization/json/README.md new file mode 100644 index 00000000..96525639 --- /dev/null +++ b/src/serialization/json/README.md @@ -0,0 +1,39 @@ +# Kiota Json Serialization Library for dotnet + +The Json Serialization Library for dotnet is the dotnet JSON serialization library implementation with System.Text.Json + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a json serialization package to handle json payloads from an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Kiota Json Serialization Library + +```shell +dotnet add package Microsoft.Kiota.Serialization.Json +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Serialization.Json.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/serialization/json/TypeConstants.cs b/src/serialization/json/TypeConstants.cs new file mode 100644 index 00000000..2a1dfa9e --- /dev/null +++ b/src/serialization/json/TypeConstants.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Serialization.Json; + +static internal class TypeConstants +{ + static readonly internal Type BooleanType = typeof(bool?); + static readonly internal Type ByteType = typeof(byte?); + static readonly internal Type SbyteType = typeof(sbyte?); + static readonly internal Type StringType = typeof(string); + static readonly internal Type IntType = typeof(int?); + static readonly internal Type FloatType = typeof(float?); + static readonly internal Type LongType = typeof(long?); + static readonly internal Type DoubleType = typeof(double?); + static readonly internal Type DecimalType = typeof(decimal?); + static readonly internal Type GuidType = typeof(Guid?); + static readonly internal Type DateTimeOffsetType = typeof(DateTimeOffset?); + static readonly internal Type TimeSpanType = typeof(TimeSpan?); + static readonly internal Type DateType = typeof(Date?); + static readonly internal Type TimeType = typeof(Time?); +} \ No newline at end of file diff --git a/src/serialization/multipart/Changelog-old.md b/src/serialization/multipart/Changelog-old.md new file mode 100644 index 00000000..8b8874c2 --- /dev/null +++ b/src/serialization/multipart/Changelog-old.md @@ -0,0 +1,48 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.1.5] - 2024-05-23 + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [microsoft/kiota-http-dotnet#256](https://github.com/microsoft/kiota-http-dotnet/issues/258) + +## [1.1.4] - 2024-04-19 + +### Changed + +- Switch to license expression & bump abstractions () + +## [1.1.3] - 2024-02-26 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.1.2] - 2024-02-05 + +### Changed + +- Fixes `IsTrimmable` property on the project. + +## [1.1.1] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.1.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +### Added + +### Changed diff --git a/src/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.csproj b/src/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.csproj new file mode 100644 index 00000000..ebaf50ca --- /dev/null +++ b/src/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.csproj @@ -0,0 +1,15 @@ + + + + + Kiota Multipart serialization provider implementation. + Kiota Multipart Serialization Library for dotnet + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + + + + + + diff --git a/src/serialization/multipart/MultipartSerializationWriter.cs b/src/serialization/multipart/MultipartSerializationWriter.cs new file mode 100644 index 00000000..933da1d9 --- /dev/null +++ b/src/serialization/multipart/MultipartSerializationWriter.cs @@ -0,0 +1,137 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Multipart; +/// +/// Serialization writer for multipart payloads. +/// +public class MultipartSerializationWriter : ISerializationWriter +{ + private readonly MemoryStream _stream = new MemoryStream(); + /// + public Action? OnBeforeObjectSerialization { get; set; } + /// + public Action? OnAfterObjectSerialization { get; set; } + /// + public Action? OnStartObjectSerialization { get; set; } + private readonly StreamWriter writer; + /// + /// Instantiates a new multipart serialization writer. + /// + public MultipartSerializationWriter() + { + writer = new StreamWriter(_stream, + // Default encoding + encoding: new System.Text.UTF8Encoding(false, true), + // Default buffer size + bufferSize: 1024, + leaveOpen: true) + { + AutoFlush = true, // important as we also write to the stream directly + NewLine = "\r\n", // http spec + }; + } + /// + public void Dispose() + { + writer.Dispose(); + GC.SuppressFinalize(this); + } + /// + public Stream GetSerializedContent() + { + writer.Flush(); + _stream.Position = 0; + return _stream; + } + /// + public void WriteAdditionalData(IDictionary value) => throw new NotImplementedException(); + /// + public void WriteBoolValue(string? key, bool? value) => throw new NotImplementedException(); + /// + public void WriteByteArrayValue(string? key, byte[]? value) + { + if(value != null && value.Length > 0) + { + _stream.Write(value, 0, value.Length); + } + } + /// + public void WriteByteValue(string? key, byte? value) => throw new NotImplementedException(); + /// +#if NET5_0_OR_GREATER + public void WriteCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, IEnumerable? values) where T : struct, Enum => throw new NotImplementedException(); +#else + public void WriteCollectionOfEnumValues(string? key, IEnumerable? values) where T : struct, Enum => throw new NotImplementedException(); +#endif + /// + public void WriteCollectionOfObjectValues(string? key, IEnumerable? values) where T : IParsable => throw new NotImplementedException(); + /// + public void WriteCollectionOfPrimitiveValues(string? key, IEnumerable? values) => throw new NotImplementedException(); + /// + public void WriteDateTimeOffsetValue(string? key, DateTimeOffset? value) => throw new NotImplementedException(); + /// + public void WriteDateValue(string? key, Date? value) => throw new NotImplementedException(); + /// + public void WriteDecimalValue(string? key, decimal? value) => throw new NotImplementedException(); + /// + public void WriteDoubleValue(string? key, double? value) => throw new NotImplementedException(); + /// +#if NET5_0_OR_GREATER + public void WriteEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, T? value) where T : struct, Enum => throw new NotImplementedException(); +#else + public void WriteEnumValue(string? key, T? value) where T : struct, Enum => throw new NotImplementedException(); +#endif + /// + public void WriteFloatValue(string? key, float? value) => throw new NotImplementedException(); + /// + public void WriteGuidValue(string? key, Guid? value) => throw new NotImplementedException(); + /// + public void WriteIntValue(string? key, int? value) => throw new NotImplementedException(); + /// + public void WriteLongValue(string? key, long? value) => throw new NotImplementedException(); + /// + public void WriteNullValue(string? key) => throw new NotImplementedException(); + /// + public void WriteObjectValue(string? key, T? value, params IParsable?[] additionalValuesToMerge) where T : IParsable + { + if(value is MultipartBody) + { + OnBeforeObjectSerialization?.Invoke(value); + OnStartObjectSerialization?.Invoke(value, this); + value.Serialize(this); + OnAfterObjectSerialization?.Invoke(value); + } + else + throw new InvalidOperationException($"Expected a MultiPartBody instance, but got {value?.GetType().Name ?? "null"}"); + } + /// + public void WriteSbyteValue(string? key, sbyte? value) => throw new NotImplementedException(); + /// + public void WriteStringValue(string? key, string? value) + { + if(!string.IsNullOrEmpty(key)) + writer.Write(key); + if(!string.IsNullOrEmpty(value)) + { + if(!string.IsNullOrEmpty(key)) + writer.Write(": "); + writer.Write(value); + } + writer.WriteLine(); + } + /// + public void WriteTimeSpanValue(string? key, TimeSpan? value) => throw new NotImplementedException(); + /// + public void WriteTimeValue(string? key, Time? value) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/src/serialization/multipart/MultipartSerializationWriterFactory.cs b/src/serialization/multipart/MultipartSerializationWriterFactory.cs new file mode 100644 index 00000000..7a22c3b7 --- /dev/null +++ b/src/serialization/multipart/MultipartSerializationWriterFactory.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using Microsoft.Kiota.Abstractions.Serialization; +namespace Microsoft.Kiota.Serialization.Multipart; +/// +/// Factory to create multipart serialization writers. +/// +public class MultipartSerializationWriterFactory : ISerializationWriterFactory +{ + /// + public string ValidContentType => "multipart/form-data"; + /// + public ISerializationWriter GetSerializationWriter(string contentType) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + return new MultipartSerializationWriter(); + } +} diff --git a/src/serialization/multipart/README.md b/src/serialization/multipart/README.md new file mode 100644 index 00000000..ec69ca91 --- /dev/null +++ b/src/serialization/multipart/README.md @@ -0,0 +1,39 @@ +# Kiota Multipart Serialization Library for dotnet + +The Multipart Serialization Library for dotnet is the dotnet Multipart serialization library implementation. + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a multipart serialization package to handle multipart payloads from an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Kiota Multipart Serialization Library + +```shell +dotnet add package Microsoft.Kiota.Serialization.Multipart +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Serialization.Multipart.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/serialization/text/Changelog-old.md b/src/serialization/text/Changelog-old.md new file mode 100644 index 00000000..968da96a --- /dev/null +++ b/src/serialization/text/Changelog-old.md @@ -0,0 +1,130 @@ +# Changelog (old) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.2.2] - 2024-05-24 + +### Changed + +- Remove all LINQ usage from repo + +## [1.2.1] - 2024-05-23 + +### Changed + +- Fixed an issue where fixed versions of abstractions would result in restore failures. [microsoft/kiota-http-dotnet#256](https://github.com/microsoft/kiota-http-dotnet/issues/258) + +## [1.2.0] - 2024-05-13 + +### Added + +- Implements IAsyncParseNodeFactory interface which adds async support + +## [1.1.5] - 2024-04-19 + +### Changed + +- Switch to license expression & bump abstractions () + +### Changed + +## [1.1.4] - 2024-02-27 + +### Changed + +- Reduced `DynamicallyAccessedMembers` scope for enum methods to prevent ILC warnings. + +## [1.1.3] - 2024-02-26 + +### Changed + +- Added `net6.0` and `net8.0` as target frameworks. + +## [1.1.2] - 2024-01-30 + +### Changed + +- Fixed AOT warnings with reflection being used for enum types. + +## [1.1.1] - 2023-11-15 + +### Added + +- Added support for dotnet 8. + +## [1.1.0] - 2023-10-23 + +### Added + +- Added support for dotnet trimming. + +## [1.0.3] - 2023-07-14 + +### Changed + +- Change to encoding on underlying stream [#85](https://github.com/microsoft/kiota-serialization-text-dotnet/issues/85) to match default used by `StreamWriter` class. + +## [1.0.2] - 2023-07-12 + +### Changed + +- Fix for unreadable stream [#82](https://github.com/microsoft/kiota-serialization-text-dotnet/issues/82) + +## [1.0.1] - 2023-03-10 + +### Changed + +- Bumps abstraction dependency + +## [1.0.0] - 2023-02-27 + +### Added + +- GA release + +## [1.0.0-rc.3] - 2023-01-27 + +### Changed + +- Relaxed nullability tolerance when merging objects for composed types. + +## [1.0.0-rc.2] - 2023-01-17 + +### Changed + +- Adds support for nullable reference types + +## [1.0.0-rc.1] - 2022-12-15 + +### Changed + +- Release candidate 1 + +## [1.0.0-preview.4] - 2022-09-02 + +### Added + +- Added support for composed types serialization. + +## [1.0.0-preview.3] - 2022-05-18 + +### Changed + +- Updated abstractions version to 1.0.0.preview8 + +## [1.0.0-preview.2] - 2022-04-12 + +### Changed + +- Breaking: Changes target runtime to netstandard2.0 + +## [1.0.0-preview.1] - 2022-03-18 + +### Added + +- Initial Nuget release diff --git a/src/serialization/text/Microsoft.Kiota.Serialization.Text.csproj b/src/serialization/text/Microsoft.Kiota.Serialization.Text.csproj new file mode 100644 index 00000000..1dd40038 --- /dev/null +++ b/src/serialization/text/Microsoft.Kiota.Serialization.Text.csproj @@ -0,0 +1,15 @@ + + + + + Text/plain serialization provider implementation for Kiota based SDKs. + Text/plain serialization Kiota library for dotnet + + netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 + true + + + + + + diff --git a/src/serialization/text/README.md b/src/serialization/text/README.md new file mode 100644 index 00000000..59b46aa9 --- /dev/null +++ b/src/serialization/text/README.md @@ -0,0 +1,39 @@ +# Kiota Text Serialization Library for dotnet + +The Text Serialization Library for dotnet is the dotnet Text serialization library implementation to handle text/plain responses. + +A [Kiota](https://github.com/microsoft/kiota) generated project will need a reference to a Text serialization package to handle text/plain payloads from an API endpoint. + +Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). + +## Using the Kiota Text Serialization Library + +```shell +dotnet add package Microsoft.Kiota.Serialization.Text +``` + +## Debugging + +If you are using Visual Studio Code as your IDE, the **launch.json** file already contains the configuration to build and test the library. Otherwise, you can open the **Microsoft.Kiota.Serialization.Text.sln** with Visual Studio. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/src/serialization/text/TextParseNode.cs b/src/serialization/text/TextParseNode.cs new file mode 100644 index 00000000..5f4dda66 --- /dev/null +++ b/src/serialization/text/TextParseNode.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Xml; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Text; +/// +/// The implementation for the text/plain content type +/// +public class TextParseNode : IParseNode +{ + internal const string NoStructuredDataMessage = "text does not support structured data"; + private readonly string? Text; + /// + /// Initializes a new instance of the class. + /// + /// The text value. + public TextParseNode(string? text) + { + Text = text?.Trim('"'); + } + /// + public Action? OnBeforeAssignFieldValues { get; set; } + /// + public Action? OnAfterAssignFieldValues { get; set; } + /// + public bool? GetBoolValue() => bool.TryParse(Text, out var result) ? result : null; + /// + public byte[]? GetByteArrayValue() => string.IsNullOrEmpty(Text) ? null : Convert.FromBase64String(Text); + /// + public byte? GetByteValue() => byte.TryParse(Text, out var result) ? result : null; + /// + public IParseNode GetChildNode(string identifier) => throw new InvalidOperationException(NoStructuredDataMessage); + /// + public IEnumerable GetCollectionOfObjectValues(ParsableFactory factory) where T : IParsable => throw new InvalidOperationException(NoStructuredDataMessage); + /// + public IEnumerable GetCollectionOfPrimitiveValues() => throw new InvalidOperationException(NoStructuredDataMessage); + /// + public DateTimeOffset? GetDateTimeOffsetValue() => DateTimeOffset.TryParse(Text, out var result) ? result : null; + /// + public Date? GetDateValue() => DateTime.TryParse(Text, out var result) ? new Date(result) : null; + /// + public decimal? GetDecimalValue() => decimal.TryParse(Text, out var result) ? result : null; + /// + public double? GetDoubleValue() => double.TryParse(Text, out var result) ? result : null; + /// + public float? GetFloatValue() => float.TryParse(Text, out var result) ? result : null; + /// + public Guid? GetGuidValue() => Guid.TryParse(Text, out var result) ? result : null; + /// + public int? GetIntValue() => int.TryParse(Text, out var result) ? result : null; + /// + public long? GetLongValue() => long.TryParse(Text, out var result) ? result : null; + /// + public T GetObjectValue(ParsableFactory factory) where T : IParsable => throw new InvalidOperationException(NoStructuredDataMessage); + /// + public sbyte? GetSbyteValue() => sbyte.TryParse(Text, out var result) ? result : null; + /// + public string? GetStringValue() => Text; + /// + public TimeSpan? GetTimeSpanValue() => string.IsNullOrEmpty(Text) ? null : XmlConvert.ToTimeSpan(Text); + /// + public Time? GetTimeValue() => DateTime.TryParse(Text, out var result) ? new Time(result) : null; + /// +#if NET5_0_OR_GREATER + public IEnumerable GetCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() where T : struct, Enum +#else + public IEnumerable GetCollectionOfEnumValues() where T : struct, Enum +#endif + => throw new InvalidOperationException(NoStructuredDataMessage); + /// +#if NET5_0_OR_GREATER + public T? GetEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() where T : struct, Enum +#else + public T? GetEnumValue() where T : struct, Enum +#endif + => Enum.TryParse(Text, true, out var result) ? result : null; +} diff --git a/src/serialization/text/TextParseNodeFactory.cs b/src/serialization/text/TextParseNodeFactory.cs new file mode 100644 index 00000000..751a76c4 --- /dev/null +++ b/src/serialization/text/TextParseNodeFactory.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Text; + +/// +/// The implementation for text/plain content types +/// +public class TextParseNodeFactory : IAsyncParseNodeFactory +{ + /// + public string ValidContentType => "text/plain"; + /// + public IParseNode GetRootParseNode(string contentType, Stream content) { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + _ = content ?? throw new ArgumentNullException(nameof(content)); + using var reader = new StreamReader(content); + var stringContent = reader.ReadToEnd(); + return new TextParseNode(stringContent); + } + /// + public async Task GetRootParseNodeAsync(string contentType, Stream content, CancellationToken cancellationToken = default) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + _ = content ?? throw new ArgumentNullException(nameof(content)); + + using var reader = new StreamReader(content); + var stringContent = await reader.ReadToEndAsync().ConfigureAwait(false); + return new TextParseNode(stringContent); + } +} diff --git a/src/serialization/text/TextSerializationWriter.cs b/src/serialization/text/TextSerializationWriter.cs new file mode 100644 index 00000000..e26887ee --- /dev/null +++ b/src/serialization/text/TextSerializationWriter.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Xml; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Extensions; + +#if NET5_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.Kiota.Serialization.Text; + +/// +/// The implementation for text content types. +/// +public class TextSerializationWriter : ISerializationWriter, IDisposable +{ + private readonly MemoryStream _stream = new MemoryStream(); + private readonly StreamWriter _writer; + /// + /// Initializes a new instance of the class. + /// + public TextSerializationWriter() + { + _writer = new( + _stream, + // Default encoding + encoding: new System.Text.UTF8Encoding(false, true), + // Default buffer size + bufferSize: 1024, + leaveOpen: true); + } + private bool _written; + /// + public Action? OnBeforeObjectSerialization { get; set; } + /// + public Action? OnAfterObjectSerialization { get; set; } + /// + public Action? OnStartObjectSerialization { get; set; } + /// + public void Dispose() + { + _writer?.Dispose(); + GC.SuppressFinalize(this); + } + /// + public Stream GetSerializedContent() + { + _writer.Flush(); + _stream.Position = 0; + return _stream; + } + /// + public void WriteAdditionalData(IDictionary value) => throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + /// + public void WriteBoolValue(string? key, bool? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteByteArrayValue(string? key, byte[]? value) => WriteStringValue(key, value?.Length > 0 ? Convert.ToBase64String(value) : string.Empty); + /// + public void WriteByteValue(string? key, byte? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteCollectionOfObjectValues(string? key, IEnumerable? values) where T : IParsable => throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + /// + public void WriteCollectionOfPrimitiveValues(string? key, IEnumerable? values) => throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + /// + public void WriteDateTimeOffsetValue(string? key, DateTimeOffset? value) => WriteStringValue(key, value.HasValue ? value.Value.ToString() : null); + /// + public void WriteDateValue(string? key, Date? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteDecimalValue(string? key, decimal? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteDoubleValue(string? key, double? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteFloatValue(string? key, float? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteGuidValue(string? key, Guid? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteIntValue(string? key, int? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteLongValue(string? key, long? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteNullValue(string? key) => WriteStringValue(key, "null"); + /// + public void WriteObjectValue(string? key, T? value, params IParsable?[] additionalValuesToMerge) where T : IParsable => throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + /// + public void WriteSbyteValue(string? key, sbyte? value) => WriteStringValue(key, value?.ToString()); + /// + public void WriteStringValue(string? key, string? value) + { + if(!string.IsNullOrEmpty(key)) + throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + if(!string.IsNullOrEmpty(value)) + if(_written) + throw new InvalidOperationException("a value was already written for this serialization writer, text content only supports a single value"); + else + { + _writer.Write(value); + _written = true; + } + } + /// + public void WriteTimeSpanValue(string? key, TimeSpan? value) => WriteStringValue(key, value.HasValue ? XmlConvert.ToString(value.Value) : null); + /// + public void WriteTimeValue(string? key, Time? value) => WriteStringValue(key, value?.ToString()); + /// +#if NET5_0_OR_GREATER + public void WriteCollectionOfEnumValues<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, IEnumerable? values) where T : struct, Enum +#else + public void WriteCollectionOfEnumValues(string? key, IEnumerable? values) where T : struct, Enum +#endif + => throw new InvalidOperationException(TextParseNode.NoStructuredDataMessage); + /// +#if NET5_0_OR_GREATER + public void WriteEnumValue<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(string? key, T? value) where T : struct, Enum +#else + public void WriteEnumValue(string? key, T? value) where T : struct, Enum +#endif + => WriteStringValue(key, value.HasValue ? value.Value.ToString().ToFirstCharacterLowerCase() : null); +} diff --git a/src/serialization/text/TextSerializationWriterFactory.cs b/src/serialization/text/TextSerializationWriterFactory.cs new file mode 100644 index 00000000..91e9a45f --- /dev/null +++ b/src/serialization/text/TextSerializationWriterFactory.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Text; + +/// +/// The implementation for the text content type +/// +public class TextSerializationWriterFactory : ISerializationWriterFactory { + /// + public string ValidContentType { get; } = "text/plain"; + + /// + public ISerializationWriter GetSerializationWriter(string contentType) + { + if(string.IsNullOrEmpty(contentType)) + throw new ArgumentNullException(nameof(contentType)); + else if(!ValidContentType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentOutOfRangeException($"expected a {ValidContentType} content type"); + + return new TextSerializationWriter(); + } +} diff --git a/Microsoft.Kiota.Abstractions.Tests/ApiClientBuilderTests.cs b/tests/abstractions/ApiClientBuilderTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/ApiClientBuilderTests.cs rename to tests/abstractions/ApiClientBuilderTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Authentication/ApiKeyAuthenticationProviderTests.cs b/tests/abstractions/Authentication/ApiKeyAuthenticationProviderTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Authentication/ApiKeyAuthenticationProviderTests.cs rename to tests/abstractions/Authentication/ApiKeyAuthenticationProviderTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Authentication/AuthenticationTests.cs b/tests/abstractions/Authentication/AuthenticationTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Authentication/AuthenticationTests.cs rename to tests/abstractions/Authentication/AuthenticationTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/EnumHelperTests.cs b/tests/abstractions/EnumHelperTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/EnumHelperTests.cs rename to tests/abstractions/EnumHelperTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/IEnumerableExtensionsTests.cs b/tests/abstractions/IEnumerableExtensionsTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/IEnumerableExtensionsTests.cs rename to tests/abstractions/IEnumerableExtensionsTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Microsoft.Kiota.Abstractions.Tests.csproj b/tests/abstractions/Microsoft.Kiota.Abstractions.Tests.csproj similarity index 82% rename from Microsoft.Kiota.Abstractions.Tests/Microsoft.Kiota.Abstractions.Tests.csproj rename to tests/abstractions/Microsoft.Kiota.Abstractions.Tests.csproj index 1d73a034..df8027cf 100644 --- a/Microsoft.Kiota.Abstractions.Tests/Microsoft.Kiota.Abstractions.Tests.csproj +++ b/tests/abstractions/Microsoft.Kiota.Abstractions.Tests.csproj @@ -1,11 +1,9 @@ - + - false - net462;net8.0 - latest - Library true + net8.0;net462 + disable @@ -30,7 +28,7 @@ - + \ No newline at end of file diff --git a/Microsoft.Kiota.Abstractions.Tests/Mocks/TestEntity.cs b/tests/abstractions/Mocks/TestEntity.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Mocks/TestEntity.cs rename to tests/abstractions/Mocks/TestEntity.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Mocks/TestEnum.cs b/tests/abstractions/Mocks/TestEnum.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Mocks/TestEnum.cs rename to tests/abstractions/Mocks/TestEnum.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Mocks/TestEnumWithFlags.cs b/tests/abstractions/Mocks/TestEnumWithFlags.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Mocks/TestEnumWithFlags.cs rename to tests/abstractions/Mocks/TestEnumWithFlags.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/MultipartBodyTests.cs b/tests/abstractions/MultipartBodyTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/MultipartBodyTests.cs rename to tests/abstractions/MultipartBodyTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/RequestHeadersTests.cs b/tests/abstractions/RequestHeadersTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/RequestHeadersTests.cs rename to tests/abstractions/RequestHeadersTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs b/tests/abstractions/RequestInformationTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs rename to tests/abstractions/RequestInformationTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Serialization/DeserializationHelpersTests.cs b/tests/abstractions/Serialization/DeserializationHelpersTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Serialization/DeserializationHelpersTests.cs rename to tests/abstractions/Serialization/DeserializationHelpersTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Serialization/Mocks/TestEntity.cs b/tests/abstractions/Serialization/Mocks/TestEntity.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Serialization/Mocks/TestEntity.cs rename to tests/abstractions/Serialization/Mocks/TestEntity.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Serialization/ParseNodeFactoryRegistryTests.cs b/tests/abstractions/Serialization/ParseNodeFactoryRegistryTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Serialization/ParseNodeFactoryRegistryTests.cs rename to tests/abstractions/Serialization/ParseNodeFactoryRegistryTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Serialization/SerializationHelpersTests.cs b/tests/abstractions/Serialization/SerializationHelpersTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Serialization/SerializationHelpersTests.cs rename to tests/abstractions/Serialization/SerializationHelpersTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Serialization/SerializationWriterFactoryRegistryTests.cs b/tests/abstractions/Serialization/SerializationWriterFactoryRegistryTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Serialization/SerializationWriterFactoryRegistryTests.cs rename to tests/abstractions/Serialization/SerializationWriterFactoryRegistryTests.cs diff --git a/Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs b/tests/abstractions/Store/InMemoryBackingStoreTests.cs similarity index 100% rename from Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs rename to tests/abstractions/Store/InMemoryBackingStoreTests.cs diff --git a/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs b/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs new file mode 100644 index 00000000..2c2e63c3 --- /dev/null +++ b/tests/authentication/azure/AzureIdentityAuthenticationProviderTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Azure.Core; +using Microsoft.Kiota.Abstractions; + +namespace Microsoft.Kiota.Authentication.Azure.Tests; +public class AzureIdentityAuthenticationProviderTests +{ + [Fact] + public void ConstructorThrowsArgumentNullExceptionOnNullTokenCredential() + { + // Arrange + var exception = Assert.Throws(() => new AzureIdentityAccessTokenProvider(null, null)); + + // Assert + Assert.Equal("credential", exception.ParamName); + } + + [Theory] + [InlineData("https://localhost", "")] + [InlineData("https://graph.microsoft.com", "token")] + [InlineData("https://graph.microsoft.com/v1.0/me", "token")] + public async Task GetAuthorizationTokenAsyncGetsToken(string url, string expectedToken) + { + // Arrange + var uri = new Uri(url); + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(expectedToken, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + // Act + var token = await azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(uri); + + // Assert + Assert.Equal(expectedToken, token); + mockTokenCredential.Verify(x => x.GetTokenAsync(It.Is(t => + t.Scopes.Any(s => $"{uri.Scheme}://{uri.Host}/.default".Equals(s, StringComparison.OrdinalIgnoreCase))), It.IsAny())); + } + + [Theory] + [InlineData("https://localhost", "")] + [InlineData("https://graph.microsoft.com", "token")] + [InlineData("https://graph.microsoft.com/v1.0/me", "token")] + public async Task AuthenticateRequestAsyncSetsBearerHeader(string url, string expectedToken) + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(expectedToken, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAuthenticationProvider(mockTokenCredential.Object, scopes: "User.Read"); + var testRequest = new RequestInformation() + { + HttpMethod = Method.GET, + URI = new Uri(url) + }; + Assert.Empty(testRequest.Headers); // header collection is empty + + // Act + await azureIdentityAuthenticationProvider.AuthenticateRequestAsync(testRequest); + + // Assert + if(string.IsNullOrEmpty(expectedToken)) + { + Assert.Empty(testRequest.Headers); // header collection is still empty + } + else + { + Assert.NotEmpty(testRequest.Headers); // header collection is no longer empty + Assert.Equal("Authorization", testRequest.Headers.First().Key); // First element is Auth header + Assert.Equal($"Bearer {expectedToken}", testRequest.Headers["Authorization"].First()); // First element is Auth header + } + } + + [Fact] + public async Task GetAuthorizationTokenAsyncThrowsExcpetionForNonHTTPsUrl() + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + var nonHttpsUrl = "http://graph.microsoft.com"; + + // Assert + var exception = await Assert.ThrowsAsync(() => azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(new Uri(nonHttpsUrl))); + Assert.Equal("Only https is supported", exception.Message); + } + + [Theory] + [InlineData("http://localhost/test")] + [InlineData("http://localhost:8080/test")] + [InlineData("http://127.0.0.1:8080/test")] + [InlineData("http://127.0.0.1/test")] + public async Task GetAuthorizationTokenAsyncDoesNotThrowsExcpetionForNonHTTPsUrlIfLocalHost(string nonHttpsUrl) + { + // Arrange + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())).Returns(new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now))); + var azureIdentityAuthenticationProvider = new AzureIdentityAccessTokenProvider(mockTokenCredential.Object); + + // Assert + var token = await azureIdentityAuthenticationProvider.GetAuthorizationTokenAsync(new Uri(nonHttpsUrl)); + Assert.Empty(token); + } + [Fact] + public async Task AddsClaimsToTheTokenContext() + { + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) + .Returns((context, cToken) => { + Assert.NotNull(context.Claims); + return new ValueTask(new AccessToken(string.Empty, DateTimeOffset.Now)); + }); + var azureIdentityAuthenticationProvider = new AzureIdentityAuthenticationProvider(mockTokenCredential.Object, scopes: "User.Read"); + var testRequest = new RequestInformation() + { + HttpMethod = Method.GET, + URI = new Uri("https://graph.microsoft.com/v1.0/me") + }; + Assert.Empty(testRequest.Headers); // header collection is empty + + // Act + await azureIdentityAuthenticationProvider.AuthenticateRequestAsync(testRequest, new() { {"claims", "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTY1MjgxMzUwOCJ9fX0="}}); + mockTokenCredential.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj b/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj new file mode 100644 index 00000000..da531069 --- /dev/null +++ b/tests/authentication/azure/Microsoft.Kiota.Authentication.Azure.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net462 + true + disable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/http/httpClient/Extensions/HttpRequestMessageExtensionsTests.cs b/tests/http/httpClient/Extensions/HttpRequestMessageExtensionsTests.cs new file mode 100644 index 00000000..2eba6b50 --- /dev/null +++ b/tests/http/httpClient/Extensions/HttpRequestMessageExtensionsTests.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Extensions +{ + public class HttpRequestMessageExtensionsTests + { + private readonly HttpClientRequestAdapter requestAdapter; + public HttpRequestMessageExtensionsTests() + { + requestAdapter = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + } + [Fact] + public async Task GetRequestOptionCanExtractRequestOptionFromHttpRequestMessage() + { + // Arrange + var requestInfo = new RequestInformation() + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var redirectHandlerOption = new RedirectHandlerOption() + { + MaxRedirect = 7 + }; + requestInfo.AddRequestOptions(new IRequestOption[] { redirectHandlerOption }); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + var extractedOption = requestMessage.GetRequestOption(); + // Assert + Assert.NotNull(extractedOption); + Assert.Equal(redirectHandlerOption, extractedOption); + Assert.Equal(7, redirectHandlerOption.MaxRedirect); + } + + [Fact] + public async Task CloneAsyncWithEmptyHttpContent() + { + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + HttpRequestMessage clonedRequest = await originalRequest.CloneAsync(); + + Assert.NotNull(clonedRequest); + Assert.Equal(originalRequest.Method, clonedRequest.Method); + Assert.Equal(originalRequest.RequestUri, clonedRequest.RequestUri); + Assert.Null(clonedRequest.Content); + } + + [Fact] + public async Task CloneAsyncWithHttpContent() + { + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + originalRequest.Content = new StringContent("contents"); + + var clonedRequest = await originalRequest.CloneAsync(); + var originalContents = await originalRequest.Content.ReadAsStringAsync(); + var clonedRequestContents = await clonedRequest.Content?.ReadAsStringAsync(); + + Assert.NotNull(clonedRequest); + Assert.Equal(originalRequest.Method, clonedRequest.Method); + Assert.Equal(originalRequest.RequestUri, clonedRequest.RequestUri); + Assert.Equal(originalContents, clonedRequestContents); + Assert.Equal(originalRequest.Content?.Headers.ContentType, clonedRequest.Content?.Headers.ContentType); + } + + [Fact] + public async Task CloneAsyncWithHttpStreamContent() + { + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + requestInfo.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes("contents")), "application/octet-stream"); + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + var clonedRequest = await originalRequest.CloneAsync(); + var originalContents = await originalRequest.Content.ReadAsStringAsync(); + var clonedRequestContents = await clonedRequest.Content?.ReadAsStringAsync(); + + Assert.NotNull(clonedRequest); + Assert.Equal(originalRequest.Method, clonedRequest.Method); + Assert.Equal(originalRequest.RequestUri, clonedRequest.RequestUri); + Assert.Equal(originalContents, clonedRequestContents); + Assert.Equal(originalRequest.Content?.Headers.ContentType, clonedRequest.Content?.Headers.ContentType); + } + + [Fact] + public async Task CloneAsyncWithRequestOption() + { + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var redirectHandlerOption = new RedirectHandlerOption() + { + MaxRedirect = 7 + }; + requestInfo.AddRequestOptions(new IRequestOption[] { redirectHandlerOption }); + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + originalRequest.Content = new StringContent("contents"); + + var clonedRequest = await originalRequest.CloneAsync(); + + Assert.NotNull(clonedRequest); + Assert.Equal(originalRequest.Method, clonedRequest.Method); + Assert.Equal(originalRequest.RequestUri, clonedRequest.RequestUri); +#if NET5_0_OR_GREATER + Assert.NotEmpty(clonedRequest.Options); + Assert.Equal(redirectHandlerOption, clonedRequest.Options.First().Value); +#else + Assert.NotEmpty(clonedRequest.Properties); + Assert.Equal(redirectHandlerOption, clonedRequest.Properties.First().Value); +#endif + Assert.Equal(originalRequest.Content?.Headers.ContentType, clonedRequest.Content?.Headers.ContentType); + } + + [Fact] + public async Task IsBufferedReturnsTrueForGetRequest() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + // Act + var response = originalRequest.IsBuffered(); + // Assert + Assert.True(response, "Unexpected content type"); + } + [Fact] + public async Task IsBufferedReturnsTrueForPostWithNoContent() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.POST, + URI = new Uri("http://localhost") + }; + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + // Act + var response = originalRequest.IsBuffered(); + // Assert + Assert.True(response, "Unexpected content type"); + } + [Fact] + public async Task IsBufferedReturnsTrueForPostWithBufferStringContent() + { + // Arrange + byte[] data = new byte[] { 1, 2, 3, 4, 5 }; + var requestInfo = new RequestInformation + { + HttpMethod = Method.POST, + URI = new Uri("http://localhost"), + Content = new MemoryStream(data) + }; + var originalRequest = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + // Act + var response = originalRequest.IsBuffered(); + // Assert + Assert.True(response, "Unexpected content type"); + } + } +} diff --git a/tests/http/httpClient/KiotaClientFactoryTests.cs b/tests/http/httpClient/KiotaClientFactoryTests.cs new file mode 100644 index 00000000..ce914bd3 --- /dev/null +++ b/tests/http/httpClient/KiotaClientFactoryTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests +{ + public class KiotaClientFactoryTests + { + [Fact] + public void ChainHandlersCollectionAndGetFirstLinkReturnsNullOnDefaultParams() + { + // Act + var delegatingHandler = KiotaClientFactory.ChainHandlersCollectionAndGetFirstLink(); + // Assert + Assert.Null(delegatingHandler); + } + + [Fact] + public void ChainHandlersCollectionAndGetFirstLinkWithSingleHandler() + { + // Arrange + var handler = new TestHttpMessageHandler(); + // Act + var delegatingHandler = KiotaClientFactory.ChainHandlersCollectionAndGetFirstLink(handler); + // Assert + Assert.NotNull(delegatingHandler); + Assert.Null(delegatingHandler.InnerHandler); + } + + [Fact] + public void ChainHandlersCollectionAndGetFirstLinkWithMultipleHandlers() + { + // Arrange + var handler1 = new TestHttpMessageHandler(); + var handler2 = new TestHttpMessageHandler(); + // Act + var delegatingHandler = KiotaClientFactory.ChainHandlersCollectionAndGetFirstLink(handler1, handler2); + // Assert + Assert.NotNull(delegatingHandler); + Assert.NotNull(delegatingHandler.InnerHandler); // first handler has an inner handler + + var innerHandler = delegatingHandler.InnerHandler as DelegatingHandler; + Assert.NotNull(innerHandler); + Assert.Null(innerHandler.InnerHandler);// end of the chain + } + + [Fact] + public void ChainHandlersCollectionAndGetFirstLinkWithMultipleHandlersSetsFinalHandler() + { + // Arrange + var handler1 = new TestHttpMessageHandler(); + var handler2 = new TestHttpMessageHandler(); + var finalHandler = new HttpClientHandler(); + // Act + var delegatingHandler = KiotaClientFactory.ChainHandlersCollectionAndGetFirstLink(finalHandler, handler1, handler2); + // Assert + Assert.NotNull(delegatingHandler); + Assert.NotNull(delegatingHandler.InnerHandler); // first handler has an inner handler + + var innerHandler = delegatingHandler.InnerHandler as DelegatingHandler; + Assert.NotNull(innerHandler); + Assert.NotNull(innerHandler.InnerHandler); + Assert.IsType(innerHandler.InnerHandler); + } + + [Fact] + public void GetDefaultHttpMessageHandlerEnablesMultipleHttp2Connections() + { + // Act + var defaultHandler = KiotaClientFactory.GetDefaultHttpMessageHandler(); + // Assert + Assert.NotNull(defaultHandler); +#if NETFRAMEWORK + Assert.IsType(defaultHandler); + Assert.True(((WinHttpHandler)defaultHandler).EnableMultipleHttp2Connections); +#else + Assert.IsType(defaultHandler); + Assert.True(((SocketsHttpHandler)defaultHandler).EnableMultipleHttp2Connections); +#endif + } + + [Fact] + public void GetDefaultHttpMessageHandlerSetsUpProxy() + { + // Arrange + var proxy = new WebProxy("http://localhost:8888", false); + // Act + var defaultHandler = KiotaClientFactory.GetDefaultHttpMessageHandler(proxy); + // Assert + Assert.NotNull(defaultHandler); +#if NETFRAMEWORK + Assert.IsType(defaultHandler); + Assert.Equal(proxy, ((WinHttpHandler)defaultHandler).Proxy); +#else + Assert.IsType(defaultHandler); + Assert.Equal(proxy, ((SocketsHttpHandler)defaultHandler).Proxy); +#endif + } + + [Fact] + public void CreateDefaultHandlersWithOptions() + { + // Arrange + var retryHandlerOption = new RetryHandlerOption { MaxRetry = 5, ShouldRetry = (_, _, _) => true }; + + + // Act + var handlers = KiotaClientFactory.CreateDefaultHandlers([retryHandlerOption]); + var retryHandler = handlers.OfType().FirstOrDefault(); + + // Assert + Assert.NotNull(retryHandler); + Assert.Equal(retryHandlerOption, retryHandler.RetryOption); + } + + [Fact] + public void CreateWithNullOrEmptyHandlersReturnsHttpClient() + { + var client = KiotaClientFactory.Create(null, (HttpMessageHandler)null); + Assert.IsType(client); + + client = KiotaClientFactory.Create(new List()); + Assert.IsType(client); + } + + [Fact] + public void CreateWithCustomMiddlewarePipelineReturnsHttpClient() + { + var handlers = KiotaClientFactory.CreateDefaultHandlers(); + handlers.Add(new CompressionHandler()); + var client = KiotaClientFactory.Create(handlers); + Assert.IsType(client); + } + } +} diff --git a/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj b/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj new file mode 100644 index 00000000..9dd2aa46 --- /dev/null +++ b/tests/http/httpClient/Microsoft.Kiota.Http.HttpClientLibrary.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net462 + true + disable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/http/httpClient/Middleware/ActivitySourceRegistryTests.cs b/tests/http/httpClient/Middleware/ActivitySourceRegistryTests.cs new file mode 100644 index 00000000..0314d88b --- /dev/null +++ b/tests/http/httpClient/Middleware/ActivitySourceRegistryTests.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware.Registries +{ + public class ActivitySourceRegistryTests + { + [Fact] + public void Defensive() + { + Assert.Throws(() => ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource("")); + Assert.Throws(() => ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(null)); + } + + [Fact] + public void CreatesNewInstanceOnFirstCallAndReturnsSameInstance() + { + // Act + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource("sample source"); + Assert.NotNull(activitySource); + + var activitySource2 = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource("sample source"); + Assert.NotNull(activitySource); + + // They are the same instance + Assert.Equal(activitySource, activitySource2); + Assert.Equal("sample source", activitySource.Name); + Assert.Equal("sample source", activitySource2.Name); + } + + [Fact] + public void CreatesDifferentInstances() + { + // Act + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource("sample source"); + Assert.NotNull(activitySource); + Assert.Equal("sample source", activitySource.Name); + + var activitySource2 = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource("sample source 2"); + Assert.NotNull(activitySource); + Assert.Equal("sample source 2", activitySource2.Name); + + // They are not the same instance + Assert.NotEqual(activitySource, activitySource2); + } + } +} diff --git a/tests/http/httpClient/Middleware/ChaosHandlerTests.cs b/tests/http/httpClient/Middleware/ChaosHandlerTests.cs new file mode 100644 index 00000000..ec80fa4b --- /dev/null +++ b/tests/http/httpClient/Middleware/ChaosHandlerTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class ChaosHandlerTests + { + [Fact] + public async Task RandomChaosShouldReturnRandomFailures() + { + // Arrange + var handler = new ChaosHandler + { + InnerHandler = new FakeSuccessHandler() + }; + + var invoker = new HttpMessageInvoker(handler); + var request = new HttpRequestMessage(); + + // Act + Dictionary responses = new Dictionary(); + + // Make calls until all known failures have been triggered + while(responses.Count < 3) + { + var response = await invoker.SendAsync(request, new CancellationToken()); + if(response.StatusCode != HttpStatusCode.OK) + { + responses[response.StatusCode] = null; + } + } + + // Assert + Assert.True(responses.ContainsKey((HttpStatusCode)429)); + Assert.True(responses.ContainsKey(HttpStatusCode.ServiceUnavailable)); + Assert.True(responses.ContainsKey(HttpStatusCode.GatewayTimeout)); + } + + [Fact] + public async Task RandomChaosWithCustomKnownFailuresShouldReturnAllFailuresRandomly() + { + + // Arrange + var handler = new ChaosHandler(new ChaosHandlerOption + { + KnownChaos = new List + { + ChaosHandler.Create429TooManyRequestsResponse(new TimeSpan(0,0,5)), + ChaosHandler.Create500InternalServerErrorResponse(), + ChaosHandler.Create503Response(new TimeSpan(0,0,5)), + ChaosHandler.Create502BadGatewayResponse(), + ChaosHandler.Create504GatewayTimeoutResponse(new TimeSpan(0,0,5)) + } + }) + { + InnerHandler = new FakeSuccessHandler() + }; + + var invoker = new HttpMessageInvoker(handler); + var request = new HttpRequestMessage(); + + // Act + Dictionary responses = new Dictionary(); + + // Make calls until all known failures have been triggered + while(responses.Count < 5) + { + var response = await invoker.SendAsync(request, new CancellationToken()); + if(response.StatusCode != HttpStatusCode.OK) + { + responses[response.StatusCode] = null; + } + } + + // Assert + Assert.True(responses.ContainsKey((HttpStatusCode)429)); + Assert.True(responses.ContainsKey(HttpStatusCode.InternalServerError)); + Assert.True(responses.ContainsKey(HttpStatusCode.BadGateway)); + Assert.True(responses.ContainsKey(HttpStatusCode.ServiceUnavailable)); + Assert.True(responses.ContainsKey(HttpStatusCode.GatewayTimeout)); + } + + [Fact(Skip = "Test is flaky and needs investigation.")] + public async Task PlannedChaosShouldReturnChaosWhenPlanned() + { + // Arrange + + Func plannedChaos = (req) => + { + if(req.RequestUri?.OriginalString.Contains("/fail") ?? false) + { + return ChaosHandler.Create429TooManyRequestsResponse(new TimeSpan(0, 0, 5)); + } + return null; + }; + + var handler = new ChaosHandler(new ChaosHandlerOption + { + PlannedChaosFactory = plannedChaos + }) + { + InnerHandler = new FakeSuccessHandler() + }; + + // Act + var request1 = new HttpRequestMessage + { + RequestUri = new Uri("http://example.org/success") + }; + var response1 = await new HttpMessageInvoker(handler).SendAsync(request1, new CancellationToken()); + + var request2 = new HttpRequestMessage + { + RequestUri = new Uri("http://example.org/fail") + }; + var response2 = await new HttpMessageInvoker(handler).SendAsync(request2, new CancellationToken()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + Assert.Equal((HttpStatusCode)429, response2.StatusCode); + } + + } + + internal class FakeSuccessHandler : DelegatingHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + RequestMessage = request + }; + return Task.FromResult(response); + } + } +} diff --git a/tests/http/httpClient/Middleware/CompressionHandlerTests.cs b/tests/http/httpClient/Middleware/CompressionHandlerTests.cs new file mode 100644 index 00000000..617b3736 --- /dev/null +++ b/tests/http/httpClient/Middleware/CompressionHandlerTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class CompressionHandlerTests: IDisposable + { + private readonly MockRedirectHandler _testHttpMessageHandler; + private readonly CompressionHandler _compressionHandler; + private readonly HttpMessageInvoker _invoker; + + public CompressionHandlerTests() + { + this._testHttpMessageHandler = new MockRedirectHandler(); + this._compressionHandler = new CompressionHandler + { + InnerHandler = this._testHttpMessageHandler + }; + this._invoker = new HttpMessageInvoker(this._compressionHandler); + } + + public void Dispose() + { + this._invoker.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void CompressionHandlerShouldConstructHandler() + { + Assert.NotNull(this._compressionHandler.InnerHandler); + } + + [Fact] + public async Task CompressionHandlerShouldAddAcceptEncodingGzipHeaderWhenNonIsPresent() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(httpRequestMessage, response.RequestMessage); + Assert.NotNull(response.RequestMessage); + Assert.Contains(new StringWithQualityHeaderValue(CompressionHandler.GZip), response.RequestMessage.Headers.AcceptEncoding); + } + + [Fact] + public async Task CompressionHandlerShouldDecompressResponseWithContentEncodingGzipHeader() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue(CompressionHandler.GZip)); + string stringToCompress = "sample string content"; + // Compress response + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new MockCompressedContent(new StringContent(stringToCompress)) + }; + httpResponse.Content.Headers.ContentEncoding.Add(CompressionHandler.GZip); + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + // Act + HttpResponseMessage decompressedResponse = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + string responseContentString = await decompressedResponse.Content.ReadAsStringAsync(); + // Assert + Assert.Same(httpResponse, decompressedResponse); + Assert.Same(httpRequestMessage, decompressedResponse.RequestMessage); + Assert.Equal(stringToCompress, responseContentString); + } + + [Fact] + public async Task CompressionHandlerShouldNotDecompressResponseWithoutContentEncodingGzipHeader() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + string stringToCompress = "Microsoft Graph"; + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new MockCompressedContent(new StringContent(stringToCompress)) + }; + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + // Act + HttpResponseMessage compressedResponse = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + string responseContentString = await compressedResponse.Content.ReadAsStringAsync(); + // Assert + Assert.Same(httpResponse, compressedResponse); + Assert.Same(httpRequestMessage, compressedResponse.RequestMessage); + Assert.NotEqual(stringToCompress, responseContentString); + } + + [Fact] + public async Task CompressionHandlerShouldKeepContentHeadersAfterDecompression() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + // setup the http response + string stringToCompress = "Microsoft Graph"; + StringContent stringContent = new StringContent(stringToCompress); // setup the content object + stringContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + stringContent.Headers.ContentEncoding.Add(CompressionHandler.GZip); + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK)// setup the HttpResponseMessage object + { + Content = new MockCompressedContent(stringContent) + }; + httpResponse.Headers.CacheControl = new CacheControlHeaderValue { Private = true }; + // Examples of Custom Headers returned by Microsoft Graph + httpResponse.Headers.Add("request-id", Guid.NewGuid().ToString()); + httpResponse.Headers.Add("OData-Version", "4.0"); + + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + // Arrange + HttpResponseMessage compressedResponse = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + string decompressedResponseString = await compressedResponse.Content.ReadAsStringAsync(); + // Assert + Assert.Equal(decompressedResponseString, stringToCompress); + // Ensure that headers in the compressedResponse are the same as in the original, expected response. + Assert.NotEmpty(compressedResponse.Headers); + Assert.NotEmpty(compressedResponse.Content.Headers); + Assert.Equal(httpResponse.Headers, compressedResponse.Headers, new HttpHeaderComparer()); + Assert.Equal(httpResponse.Content.Headers, compressedResponse.Content.Headers, new HttpHeaderComparer()); + } + + private class HttpHeaderComparer : IEqualityComparer>> + { + public bool Equals(KeyValuePair> x, KeyValuePair> y) + { + // For each key, the collection of header values should be equal. + return x.Key == y.Key && x.Value.SequenceEqual(y.Value); + } + + public int GetHashCode(KeyValuePair> obj) + { + return obj.Key.GetHashCode(); + } + } + } +} diff --git a/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs b/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs new file mode 100644 index 00000000..70299acb --- /dev/null +++ b/tests/http/httpClient/Middleware/HeadersInspectionHandlerTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware; +public class HeadersInspectionHandlerTests : IDisposable +{ + private readonly List _disposables = new(); + [Fact] + public void HeadersInspectionHandlerConstruction() + { + using var defaultValue = new HeadersInspectionHandler(); + Assert.NotNull(defaultValue); + } + + [Fact] + public async Task HeadersInspectionHandlerGetsRequestHeaders() + { + var option = new HeadersInspectionHandlerOption + { + InspectRequestHeaders = true, + }; + using var invoker = GetMessageInvoker(option); + + // When + var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost"); + request.Headers.Add("test", "test"); + var response = await invoker.SendAsync(request, default); + + // Then + Assert.Equal("test", option.RequestHeaders["test"].First()); + Assert.Empty(option.ResponseHeaders); + } + [Fact] + public async Task HeadersInspectionHandlerGetsResponseHeaders() + { + var option = new HeadersInspectionHandlerOption + { + InspectResponseHeaders = true, + }; + using var invoker = GetMessageInvoker(option); + + // When + var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost"); + var response = await invoker.SendAsync(request, default); + + // Then + Assert.Equal("test", option.ResponseHeaders["test"].First()); + Assert.Empty(option.RequestHeaders); + } + private HttpMessageInvoker GetMessageInvoker(HeadersInspectionHandlerOption option = null) + { + var messageHandler = new MockRedirectHandler(); + _disposables.Add(messageHandler); + var response = new HttpResponseMessage(); + response.Headers.Add("test", "test"); + _disposables.Add(response); + messageHandler.SetHttpResponse(response); + // Given + var handler = new HeadersInspectionHandler(option) + { + InnerHandler = messageHandler + }; + _disposables.Add(handler); + return new HttpMessageInvoker(handler); + } + + public void Dispose() + { + _disposables.ForEach(static x => x.Dispose()); + GC.SuppressFinalize(this); + } +} diff --git a/tests/http/httpClient/Middleware/ParametersNameDecodingHandlerTests.cs b/tests/http/httpClient/Middleware/ParametersNameDecodingHandlerTests.cs new file mode 100644 index 00000000..f6da2dc4 --- /dev/null +++ b/tests/http/httpClient/Middleware/ParametersNameDecodingHandlerTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware; + +public class ParametersDecodingHandlerTests +{ + private readonly HttpMessageInvoker _invoker; + private readonly ParametersNameDecodingHandler decodingHandler; + private readonly HttpClientRequestAdapter requestAdapter; + public ParametersDecodingHandlerTests() + { + decodingHandler = new ParametersNameDecodingHandler{ + InnerHandler = new FakeSuccessHandler() + }; + this._invoker = new HttpMessageInvoker(decodingHandler); + requestAdapter = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + } + [InlineData("http://localhost?%24select=diplayName&api%2Dversion=2", "http://localhost/?$select=diplayName&api-version=2")] + [InlineData("http://localhost?%24select=diplayName&api%7Eversion=2", "http://localhost/?$select=diplayName&api~version=2")] + [InlineData("http://localhost?%24select=diplayName&api%2Eversion=2", "http://localhost/?$select=diplayName&api.version=2")] + [InlineData("http://localhost/path%2dsegment?%24select=diplayName&api%2Dversion=2", "http://localhost/path-segment?$select=diplayName&api-version=2")] //it's URI decoding the path segment + [InlineData("http://localhost:888?%24select=diplayName&api%2Dversion=2", "http://localhost:888/?$select=diplayName&api-version=2")] + [InlineData("http://localhost", "http://localhost/")] + [InlineData("https://google.com/?q=1%2b2", "https://google.com/?q=1%2b2")] + [InlineData("https://google.com/?q=M%26A", "https://google.com/?q=M%26A")] + [Theory] + public async Task DefaultParameterNameDecodingHandlerDecodesNames(string original, string result) + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(original) + }; + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Act + await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request stays the same + Assert.Equal(result, requestMessage.RequestUri!.ToString()); + } + + [InlineData("http://localhost?%24select=diplayName&api%2Dversion=2", "http://localhost/?$select=diplayName&api-version=2")] + [InlineData("http://localhost?%24select=diplayName&api%7Eversion=2", "http://localhost/?$select=diplayName&api~version=2")] + [InlineData("http://localhost?%24select=diplayName&api%2Eversion=2", "http://localhost/?$select=diplayName&api.version=2")] + [InlineData("http://localhost/path%2dsegment?%24select=diplayName&api%2Dversion=2", "http://localhost/path-segment?$select=diplayName&api-version=2")] //it's URI decoding the path segment + [InlineData("http://localhost:888?%24select=diplayName&api%2Dversion=2", "http://localhost:888/?$select=diplayName&api-version=2")] + [InlineData("http://localhost", "http://localhost/")] + [InlineData("https://google.com/?q=1%2B2", "https://google.com/?q=1%2B2")]//Values are not decoded + [InlineData("https://google.com/?q=M%26A", "https://google.com/?q=M%26A")]//Values are not decoded + [InlineData("https://google.com/?q%2D1=M%26A", "https://google.com/?q-1=M%26A")]//Values are not decoded but params are + [InlineData("https://google.com/?q%2D1&q=M%26A=M%26A", "https://google.com/?q-1&q=M%26A=M%26A")]//Values are not decoded but params are + [Theory] + public async Task DefaultParameterNameDecodingHandlerDecodesNamesWithMeaningFullUrlCharacters(string original, string result) + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(original) + }; + requestInfo.AddRequestOptions(new [] + { + new ParametersNameDecodingOption + { + ParametersToDecode = new List + { + '$','.', '-', '~', '+','&' // Add custom options for testing purposes + } + } + }); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Act + await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request stays the same + Assert.Equal(result, requestMessage.RequestUri!.ToString()); + } +} diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs new file mode 100644 index 00000000..4ebf1a76 --- /dev/null +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class RedirectHandlerTests: IDisposable + { + private readonly MockRedirectHandler _testHttpMessageHandler; + private readonly RedirectHandler _redirectHandler; + private readonly HttpMessageInvoker _invoker; + + public RedirectHandlerTests() + { + this._testHttpMessageHandler = new MockRedirectHandler(); + this._redirectHandler = new RedirectHandler + { + InnerHandler = _testHttpMessageHandler + }; + this._invoker = new HttpMessageInvoker(this._redirectHandler); + } + + public void Dispose() + { + this._invoker.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void RedirectHandler_Constructor() + { + // Assert + using RedirectHandler redirect = new RedirectHandler(); + Assert.Null(redirect.InnerHandler); + Assert.NotNull(redirect.RedirectOption); + Assert.Equal(5, redirect.RedirectOption.MaxRedirect); // default MaxRedirects is 5 + Assert.IsType(redirect); + } + + [Fact] + public void RedirectHandler_HttpMessageHandlerConstructor() + { + // Assert + Assert.NotNull(this._redirectHandler.InnerHandler); + Assert.NotNull(_redirectHandler.RedirectOption); + Assert.Equal(5, _redirectHandler.RedirectOption.MaxRedirect); // default MaxRedirects is 5 + Assert.Equal(this._redirectHandler.InnerHandler, this._testHttpMessageHandler); + Assert.IsType(this._redirectHandler); + } + + [Fact] + public void RedirectHandler_RedirectOptionConstructor() + { + // Assert + using RedirectHandler redirect = new RedirectHandler(new RedirectHandlerOption { MaxRedirect = 2, AllowRedirectOnSchemeChange = true}); + Assert.Null(redirect.InnerHandler); + Assert.NotNull(redirect.RedirectOption); + Assert.Equal(2, redirect.RedirectOption.MaxRedirect); + Assert.True(redirect.RedirectOption.AllowRedirectOnSchemeChange); + Assert.IsType(redirect); + } + + [Fact] + public async Task OkStatusShouldPassThrough() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse); // sets the mock response + // Act + var response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Same(response.RequestMessage, httpRequestMessage); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 not available in netstandard + public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Equal(response.RequestMessage?.Method, httpRequestMessage.Method); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotNull(response.RequestMessage?.Content); + Assert.Equal("Hello World", await response.RequestMessage.Content.ReadAsStringAsync()); + + } + + [Fact] + public async Task ShouldRedirectChangeMethodAndContent() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var redirectResponse = new HttpResponseMessage(HttpStatusCode.SeeOther); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotEqual(response.RequestMessage?.Method, httpRequestMessage.Method); + Assert.Equal(response.RequestMessage?.Method, HttpMethod.Get); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Null(response.RequestMessage?.Content); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.Null(response.RequestMessage?.Headers.Authorization); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync(httpRequestMessage, CancellationToken.None)); + // Assert + Assert.Contains("Redirects with changing schemes not allowed by default", exception.Message); + Assert.Equal("Scheme changed from https to http.", exception.InnerException?.Message); + Assert.IsType(exception); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); + Assert.Null(response.RequestMessage?.Headers.Authorization); + } + + [Fact] + public async Task RedirectWithSameHostShouldKeepAuthHeader() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.NotNull(response.RequestMessage?.Headers.Authorization); + } + + [Fact] + public async Task RedirectWithRelativeUrlShouldKeepRequestHost() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("/bar", UriKind.Relative); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal("http://example.org/bar", response.RequestMessage?.RequestUri?.AbsoluteUri); + } + + [Fact] + public async Task ExceedMaxRedirectsShouldThrowsException() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + var response1 = new HttpResponseMessage(HttpStatusCode.Redirect); + response1.Headers.Location = new Uri("http://example.org/bar"); + var response2 = new HttpResponseMessage(HttpStatusCode.Redirect); + response2.Headers.Location = new Uri("http://example.org/foo"); + this._testHttpMessageHandler.SetHttpResponse(response1, response2);// sets the mock response + // Act + var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync( + httpRequestMessage, CancellationToken.None)); + // Assert + Assert.Equal("Too many redirects performed", exception.Message); + Assert.Equal("Max redirects exceeded. Redirect count : 5", exception.InnerException?.Message); + Assert.IsType(exception); + } + } +} diff --git a/tests/http/httpClient/Middleware/RetryHandlerTests.cs b/tests/http/httpClient/Middleware/RetryHandlerTests.cs new file mode 100644 index 00000000..743b4fae --- /dev/null +++ b/tests/http/httpClient/Middleware/RetryHandlerTests.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class RetryHandlerTests : IDisposable + { + private readonly MockRedirectHandler _testHttpMessageHandler; + private readonly RetryHandler _retryHandler; + private readonly HttpMessageInvoker _invoker; + private const string RetryAfter = "Retry-After"; + private const string RetryAttempt = "Retry-Attempt"; + + public RetryHandlerTests() + { + _testHttpMessageHandler = new MockRedirectHandler(); + _retryHandler = new RetryHandler + { + InnerHandler = _testHttpMessageHandler + }; + _invoker = new HttpMessageInvoker(_retryHandler); + } + + public void Dispose() + { + _invoker.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void RetryHandlerConstructor() + { + // Act + using RetryHandler retry = new RetryHandler(); + // Assert + Assert.Null(retry.InnerHandler); + Assert.NotNull(retry.RetryOption); + Assert.Equal(RetryHandlerOption.DefaultMaxRetry, retry.RetryOption.MaxRetry); + Assert.IsType(retry); + } + + [Fact] + public void RetryHandlerHttpMessageHandlerConstructor() + { + // Assert + Assert.NotNull(_retryHandler.InnerHandler); + Assert.NotNull(_retryHandler.RetryOption); + Assert.Equal(RetryHandlerOption.DefaultMaxRetry, _retryHandler.RetryOption.MaxRetry); + Assert.Equal(_retryHandler.InnerHandler, _testHttpMessageHandler); + Assert.IsType(_retryHandler); + } + + [Fact] + public void RetryHandlerRetryOptionConstructor() + { + // Act + using RetryHandler retry = new RetryHandler(new RetryHandlerOption { MaxRetry = 5, ShouldRetry = (_, _, _) => true }); + // Assert + Assert.Null(retry.InnerHandler); + Assert.NotNull(retry.RetryOption); + Assert.Equal(5, retry.RetryOption.MaxRetry); + Assert.IsType(retry); + } + + [Fact] + public async Task OkStatusShouldPassThrough() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + var retryResponse = new HttpResponseMessage(HttpStatusCode.OK); + _testHttpMessageHandler.SetHttpResponse(retryResponse); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, retryResponse); + Assert.NotNull(response.RequestMessage); + Assert.Same(response.RequestMessage, httpRequestMessage); + Assert.False(response.RequestMessage.Headers.Contains(RetryAttempt), "The request add header wrong."); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldRetryWithAddRetryAttemptHeader(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + var retryResponse = new HttpResponseMessage(statusCode); + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, response2); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotNull(response.RequestMessage); + Assert.NotNull(response.RequestMessage.Headers); + Assert.True(response.RequestMessage.Headers.Contains(RetryAttempt)); + Assert.True(response.RequestMessage.Headers.TryGetValues(RetryAttempt, out var values)); + Assert.Single(values); + Assert.Equal(values.First(), 1.ToString()); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldRetryWithBuffedContent(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var retryResponse = new HttpResponseMessage(statusCode); + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, response2); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotNull(response.RequestMessage); + Assert.NotNull(response.RequestMessage.Content); + Assert.NotNull(response.RequestMessage.Content.Headers.ContentLength); + Assert.Equal("Hello World", await response.RequestMessage.Content.ReadAsStringAsync()); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldNotRetryWithPostStreaming(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Test Content") + }; + httpRequestMessage.Content.Headers.ContentLength = -1; + var retryResponse = new HttpResponseMessage(statusCode); + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotEqual(response, response2); + Assert.Same(response, retryResponse); + Assert.Same(response.RequestMessage, httpRequestMessage); + Assert.NotNull(response.RequestMessage); + Assert.NotNull(response.RequestMessage.Content); + Assert.NotNull(response.RequestMessage.Content.Headers.ContentLength); + Assert.Equal(response.RequestMessage.Content.Headers.ContentLength, -1); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldNotRetryWithPutStreaming(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, "http://example.org/foo") + { + Content = new StringContent("Test Content") + }; + httpRequestMessage.Content.Headers.ContentLength = -1; + var retryResponse = new HttpResponseMessage(statusCode); + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotEqual(response, response2); + Assert.Same(response.RequestMessage, httpRequestMessage); + Assert.Same(response, retryResponse); + Assert.NotNull(response.RequestMessage); + Assert.NotNull(response.RequestMessage.Content); + Assert.Equal(response.RequestMessage.Content.Headers.ContentLength, -1); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ExceedMaxRetryShouldReturn(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + var retryResponse = new HttpResponseMessage(statusCode); + var response2 = new HttpResponseMessage(statusCode); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + var retryHandler = new RetryHandler + { + InnerHandler = _testHttpMessageHandler, + RetryOption = new RetryHandlerOption { Delay = 1 } + }; + var invoker = new HttpMessageInvoker(retryHandler); + // Act + try + { + await invoker.SendAsync(httpRequestMessage, new CancellationToken()); + } + catch(Exception exception) + { + // Assert + Assert.IsType(exception); + var aggregateException = exception as AggregateException; + Assert.StartsWith("Too many retries performed.", aggregateException.Message); + Assert.All(aggregateException.InnerExceptions, innerexception => Assert.IsType(innerexception)); + Assert.All(aggregateException.InnerExceptions, innerexception => Assert.True((innerexception as ApiException).ResponseStatusCode == (int)statusCode)); + Assert.False(httpRequestMessage.Headers.TryGetValues(RetryAttempt, out _), "Don't set Retry-Attempt Header"); + } + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldDelayBasedOnRetryAfterHeaderWithSeconds(HttpStatusCode statusCode) + { + // Arrange + var retryResponse = new HttpResponseMessage(statusCode); + retryResponse.Headers.TryAddWithoutValidation(RetryAfter, 1.ToString()); + // Act + await DelayTestWithMessage(retryResponse, 1, "Init"); + // Assert + Assert.Equal("Init Work 1", Message); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldDelayBasedOnRetryAfterHeaderWithHttpDate(HttpStatusCode statusCode) + { + // Arrange + var retryResponse = new HttpResponseMessage(statusCode); + var futureTime = DateTime.Now + TimeSpan.FromSeconds(3);// 3 seconds from now + var futureTimeString = futureTime.ToString(CultureInfo.InvariantCulture.DateTimeFormat.RFC1123Pattern, CultureInfo.InvariantCulture); + Assert.Contains("GMT", futureTimeString); // http date always end in GMT according to the spec + Assert.True(retryResponse.Headers.TryAddWithoutValidation(RetryAfter, futureTimeString)); + // Act + await DelayTestWithMessage(retryResponse, 1, "Init"); + // Assert + Assert.Equal("Init Work 1", Message); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldDelayBasedOnExponentialBackOff(HttpStatusCode statusCode) + { + // Arrange + var retryResponse = new HttpResponseMessage(statusCode); + var compareMessage = "Init Work "; + + for(int count = 0; count < 3; count++) + { + // Act + await DelayTestWithMessage(retryResponse, count, "Init", 1); + // Assert + Assert.Equal(Message, compareMessage + count); + } + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldReturnSameStatusCodeWhenDelayIsGreaterThanRetryTimeLimit(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var retryResponse = new HttpResponseMessage(statusCode); + retryResponse.Headers.TryAddWithoutValidation(RetryAfter, 20.ToString()); + _retryHandler.RetryOption.RetriesTimeLimit = TimeSpan.FromSeconds(10); + this._testHttpMessageHandler.SetHttpResponse(retryResponse); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, retryResponse); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldRetryBasedOnRetryAfterHeaderWithSeconds(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var retryResponse = new HttpResponseMessage(statusCode); + retryResponse.Headers.TryAddWithoutValidation(RetryAfter, 5.ToString()); + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, response2); + Assert.NotNull(response.RequestMessage); + Assert.True(response.RequestMessage.Headers.TryGetValues(RetryAttempt, out var values), "Don't set Retry-Attempt Header"); + Assert.Single(values); + Assert.Equal(values.First(), 1.ToString()); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + } + + [Theory] + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData((HttpStatusCode)429)] // 429 + public async Task ShouldRetryBasedOnRetryAfterHeaderWithHttpDate(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + { + Content = new StringContent("Hello World") + }; + var retryResponse = new HttpResponseMessage(statusCode); + var futureTime = DateTime.Now + TimeSpan.FromSeconds(5);// 5 seconds from now + var futureTimeString = futureTime.ToString(CultureInfo.InvariantCulture.DateTimeFormat.RFC1123Pattern); + retryResponse.Headers.TryAddWithoutValidation(RetryAfter, futureTimeString); + Assert.Contains("GMT", futureTimeString); // http date always end in GMT according to the spec + var response2 = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(retryResponse, response2); + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Same(response, response2); + Assert.NotNull(response.RequestMessage); + Assert.True(response.RequestMessage.Headers.TryGetValues(RetryAttempt, out var values), "Don't set Retry-Attempt Header"); + Assert.Single(values); + Assert.Equal(values.First(), 1.ToString()); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + } + + [Theory] + [InlineData(1, HttpStatusCode.BadGateway, true)] + [InlineData(2, HttpStatusCode.BadGateway, true)] + [InlineData(3, HttpStatusCode.BadGateway, true)] + [InlineData(4, HttpStatusCode.OK, false)] + public async Task ShouldRetryBasedOnCustomShouldRetryDelegate(int expectedMaxRetry, HttpStatusCode expectedStatusCode, bool isExceptionExpected) + { + // Arrange + var request = new HttpRequestMessage(); + Queue httpResponseQueue = new(new HttpResponseMessage[] + { + new(HttpStatusCode.BadGateway) { RequestMessage = request }, + new(HttpStatusCode.BadGateway) { RequestMessage = request }, + new(HttpStatusCode.BadGateway) { RequestMessage = request }, + new(HttpStatusCode.BadGateway) { RequestMessage = request }, + new(HttpStatusCode.OK) { RequestMessage = request }, + }); + + var mockHttpMessageHandler = new Mock(MockBehavior.Loose); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), It.IsAny()) + .Returns(() => + { + HttpResponseMessage response; + try + { + response = httpResponseQueue.Dequeue(); + } + catch + { + response = new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request }; + } + return Task.FromResult(response); + }) + .Verifiable(); + + RetryHandler retryHandler = new(new RetryHandlerOption() + { + ShouldRetry = (_, _, httpResponseMessage) => httpResponseMessage.StatusCode == HttpStatusCode.BadGateway, + MaxRetry = expectedMaxRetry, + Delay = 0 + }) + { + InnerHandler = mockHttpMessageHandler.Object + }; + + HttpMessageInvoker httpMessageInvoker = new(retryHandler); + + // Act + try + { + var response = await httpMessageInvoker.SendAsync(request, new CancellationToken()); + + Assert.False(isExceptionExpected); + Assert.Equal(expectedStatusCode, response.StatusCode); + } + catch(Exception exception) + { + // Assert + Assert.IsType(exception); + var aggregateException = exception as AggregateException; + Assert.True(isExceptionExpected); + Assert.StartsWith("Too many retries performed.", aggregateException.Message); + Assert.Equal(1 + expectedMaxRetry, aggregateException.InnerExceptions.Count); + Assert.All(aggregateException.InnerExceptions, innerexception => Assert.Contains(expectedStatusCode.ToString(), innerexception.Message)); + } + + // Assert + mockHttpMessageHandler.Protected().Verify>("SendAsync", Times.Exactly(1 + expectedMaxRetry), ItExpr.IsAny(), It.IsAny()); + } + + private async Task DelayTestWithMessage(HttpResponseMessage response, int count, string message, int delay = RetryHandlerOption.MaxDelay) + { + Message = message; + await Task.Run(async () => + { + await RetryHandler.Delay(response, count, delay, out _, new CancellationToken()); + Message += " Work " + count; + }); + } + + private string Message + { + get; + set; + } + } +} diff --git a/tests/http/httpClient/Middleware/TelemetryHandlerTests.cs b/tests/http/httpClient/Middleware/TelemetryHandlerTests.cs new file mode 100644 index 00000000..82e0e48f --- /dev/null +++ b/tests/http/httpClient/Middleware/TelemetryHandlerTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class TelemetryHandlerTests + { + private readonly HttpMessageInvoker _invoker; + + private readonly HttpClientRequestAdapter requestAdapter; + public TelemetryHandlerTests() + { + var telemetryHandler = new TelemetryHandler + { + InnerHandler = new FakeSuccessHandler() + }; + this._invoker = new HttpMessageInvoker(telemetryHandler); + requestAdapter = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + } + + [Fact] + public async Task DefaultTelemetryHandlerDoesNotChangeRequest() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + Assert.Empty(requestMessage.Headers); + + // Act + var response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request stays the same + Assert.Empty(response.RequestMessage?.Headers!); + Assert.Equal(requestMessage,response.RequestMessage); + } + + [Fact] + public async Task TelemetryHandlerSelectivelyEnrichesRequestsBasedOnRequestMiddleWare() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + var telemetryHandlerOption = new TelemetryHandlerOption + { + TelemetryConfigurator = (httpRequestMessage) => + { + httpRequestMessage.Headers.Add("SdkVersion","x.x.x"); + return httpRequestMessage; + } + }; + // Configures the telemetry at the request level + requestInfo.AddRequestOptions(new IRequestOption[] { telemetryHandlerOption }); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + Assert.Empty(requestMessage.Headers); + + // Act + var response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request was enriched as expected + Assert.NotEmpty(response.RequestMessage?.Headers!); + Assert.Single(response.RequestMessage?.Headers!); + Assert.Equal("SdkVersion", response.RequestMessage?.Headers.First().Key); + Assert.Equal(requestMessage, response.RequestMessage); + } + + [Fact] + public async Task TelemetryHandlerGloballyEnrichesRequests() + { + // Arrange + // Configures the telemetry at the handler level + var telemetryHandlerOption = new TelemetryHandlerOption + { + TelemetryConfigurator = (httpRequestMessage) => + { + httpRequestMessage.Headers.Add("SdkVersion", "x.x.x"); + return httpRequestMessage; + } + }; + var handler = new TelemetryHandler(telemetryHandlerOption) + { + InnerHandler = new FakeSuccessHandler() + }; + + var invoker = new HttpMessageInvoker(handler); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost") + }; + + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo);// get a request message + Assert.Empty(requestMessage.Headers); + + // Act + var response = await invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request was enriched as expected + Assert.NotEmpty(response.RequestMessage?.Headers!); + Assert.Single(response.RequestMessage?.Headers!); + Assert.Equal("SdkVersion", response.RequestMessage?.Headers.First().Key); + Assert.Equal(requestMessage, response.RequestMessage); + } + } +} diff --git a/tests/http/httpClient/Middleware/UriReplacementHandlerTests.cs b/tests/http/httpClient/Middleware/UriReplacementHandlerTests.cs new file mode 100644 index 00000000..293edf4f --- /dev/null +++ b/tests/http/httpClient/Middleware/UriReplacementHandlerTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Moq; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware; + +public class UriReplacementOptionTests { + [Fact] + public void Does_Nothing_When_Url_Replacement_Is_Disabled() + { + var uri = new Uri("http://localhost/test"); + var disabled = new UriReplacementHandlerOption(false, new Dictionary()); + + Assert.False(disabled.IsEnabled()); + Assert.Equal(uri, disabled.Replace(uri)); + + disabled = new UriReplacementHandlerOption(false, new Dictionary{ + {"test", ""} + }); + + Assert.Equal(uri, disabled.Replace(uri)); + } + + [Fact] + public void Returns_Null_When_Url_Provided_Is_Null() + { + var disabled = new UriReplacementHandlerOption(false, new Dictionary()); + + Assert.False(disabled.IsEnabled()); + Assert.Null(disabled.Replace(null)); + } + + [Fact] + public void Replaces_Key_In_Path_With_Value() + { + var uri = new Uri("http://localhost/test"); + var option = new UriReplacementHandlerOption(true, new Dictionary{{"test", ""}}); + + Assert.True(option.IsEnabled()); + Assert.Equal("http://localhost/", option.Replace(uri)!.ToString()); + } +} + +public class UriReplacementHandlerTests +{ + [Fact] + public async Task Calls_Uri_ReplacementAsync() + { + var mockReplacement = new Mock(); + mockReplacement.Setup(static x => x.IsEnabled()).Returns(true); + mockReplacement.Setup(static x => x.Replace(It.IsAny())).Returns(new Uri("http://changed")); + + var handler = new UriReplacementHandler(mockReplacement.Object) + { + InnerHandler = new FakeSuccessHandler() + }; + var msg = new HttpRequestMessage(HttpMethod.Get, "http://localhost"); + var client = new HttpClient(handler); + await client.SendAsync(msg); + + mockReplacement.Verify(static x=> x.Replace(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task Calls_Uri_Replacement_From_Request_OptionsAsync() + { + var mockReplacement = new Mock(); + mockReplacement.Setup(static x => x.IsEnabled()).Returns(true); + mockReplacement.Setup(static x => x.Replace(It.IsAny())).Returns(new Uri("http://changed")); + + var handler = new UriReplacementHandler() + { + InnerHandler = new FakeSuccessHandler() + }; + var msg = new HttpRequestMessage(HttpMethod.Get, "http://localhost"); + SetRequestOption(msg, mockReplacement.Object); + var client = new HttpClient(handler); + await client.SendAsync(msg); + + mockReplacement.Verify(static x=> x.Replace(It.IsAny()), Times.Once()); + } + + /// + /// Sets a in . + /// + /// + /// The representation of the request. + /// The request option. + private static void SetRequestOption(HttpRequestMessage httpRequestMessage, T option) where T : IRequestOption + { +#if NET5_0_OR_GREATER + httpRequestMessage.Options.Set(new HttpRequestOptionsKey(typeof(T).FullName!), option); +#else + httpRequestMessage.Properties.Add(typeof(T).FullName!, option); +#endif + } +} diff --git a/tests/http/httpClient/Middleware/UserAgentHandlerTests.cs b/tests/http/httpClient/Middleware/UserAgentHandlerTests.cs new file mode 100644 index 00000000..81887f25 --- /dev/null +++ b/tests/http/httpClient/Middleware/UserAgentHandlerTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class UserAgentHandlerTests + { + private readonly HttpMessageInvoker _invoker; + private readonly HttpClientRequestAdapter requestAdapter; + public UserAgentHandlerTests() + { + var userAgentHandler = new UserAgentHandler + { + InnerHandler = new FakeSuccessHandler() + }; + this._invoker = new HttpMessageInvoker(userAgentHandler); + requestAdapter = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + } + + [Fact] + public async Task DisabledUserAgentHandlerDoesNotChangeRequest() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost"), + }; + requestInfo.AddRequestOptions(new [] { + new UserAgentHandlerOption + { + Enabled = false + } + }); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + Assert.Empty(requestMessage.Headers); + + // Act + var response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert the request stays the same + Assert.Empty(response.RequestMessage?.Headers!); + Assert.Equal(requestMessage,response.RequestMessage); + } + [Fact] + public async Task EnabledUserAgentHandlerAddsHeaderValue() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost"), + }; + var defaultOption = new UserAgentHandlerOption(); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + Assert.Empty(requestMessage.Headers); + + // Act + var response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert + Assert.Single(response.RequestMessage?.Headers!); + Assert.Single(response.RequestMessage?.Headers!.UserAgent); + Assert.Equal(response.RequestMessage?.Headers!.UserAgent.First().Product.Name, defaultOption.ProductName, StringComparer.OrdinalIgnoreCase); + Assert.Equal(response.RequestMessage?.Headers!.UserAgent.First().Product.Version, defaultOption.ProductVersion, StringComparer.OrdinalIgnoreCase); + Assert.Equal(response.RequestMessage?.Headers!.UserAgent.ToString(), $"{defaultOption.ProductName}/{defaultOption.ProductVersion}", StringComparer.OrdinalIgnoreCase); + Assert.Equal(requestMessage,response.RequestMessage); + } + + [Fact] + public async Task DoesntAddProductTwice() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("http://localhost"), + }; + var defaultOption = new UserAgentHandlerOption(); + // Act and get a request message + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + Assert.Empty(requestMessage.Headers); + + // Act + var response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + response = await _invoker.SendAsync(requestMessage, new CancellationToken()); + + // Assert + Assert.Single(response.RequestMessage?.Headers!); + Assert.Single(response.RequestMessage?.Headers!.UserAgent); + Assert.Equal(response.RequestMessage?.Headers!.UserAgent.ToString(), $"{defaultOption.ProductName}/{defaultOption.ProductVersion}", StringComparer.OrdinalIgnoreCase); + } + + } + +} diff --git a/tests/http/httpClient/Mocks/MockCompressedContent.cs b/tests/http/httpClient/Mocks/MockCompressedContent.cs new file mode 100644 index 00000000..ac911450 --- /dev/null +++ b/tests/http/httpClient/Mocks/MockCompressedContent.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks +{ + public class MockCompressedContent : HttpContent + { + private readonly HttpContent _originalContent; + + public MockCompressedContent(HttpContent httpContent) + { + _originalContent = httpContent; + foreach(var header in _originalContent.Headers) + Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + Stream compressedStream = new GZipStream(stream, CompressionMode.Compress, true); + await _originalContent.CopyToAsync(compressedStream); + compressedStream.Dispose(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } +} diff --git a/tests/http/httpClient/Mocks/MockEntity.cs b/tests/http/httpClient/Mocks/MockEntity.cs new file mode 100644 index 00000000..50f91c89 --- /dev/null +++ b/tests/http/httpClient/Mocks/MockEntity.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; + +public class MockEntity : IParsable +{ + public IDictionary> GetFieldDeserializers() + { + return new Dictionary>(); + } + + public void Serialize(ISerializationWriter writer) + { + + } + public static MockEntity Factory (IParseNode parseNode) => new MockEntity(); +} diff --git a/tests/http/httpClient/Mocks/MockError.cs b/tests/http/httpClient/Mocks/MockError.cs new file mode 100644 index 00000000..52b57b19 --- /dev/null +++ b/tests/http/httpClient/Mocks/MockError.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; + +public class MockError(string message) : ApiException(message), IParsable +{ + public IDictionary> GetFieldDeserializers() + { + return new Dictionary>(); + } + + public void Serialize(ISerializationWriter writer) + { + } +} + diff --git a/tests/http/httpClient/Mocks/MockRedirectHandler.cs b/tests/http/httpClient/Mocks/MockRedirectHandler.cs new file mode 100644 index 00000000..081f0445 --- /dev/null +++ b/tests/http/httpClient/Mocks/MockRedirectHandler.cs @@ -0,0 +1,44 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks +{ + public class MockRedirectHandler : HttpMessageHandler + { + private HttpResponseMessage Response1 + { + get; set; + } + private HttpResponseMessage Response2 + { + get; set; + } + + private bool _response1Sent = false; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(!_response1Sent) + { + _response1Sent = true; + Response1.RequestMessage = request; + return await Task.FromResult(Response1); + } + else + { + _response1Sent = false; + Response2.RequestMessage = request; + return await Task.FromResult(Response2); + } + } + + public void SetHttpResponse(HttpResponseMessage response1, HttpResponseMessage response2 = null) + { + this._response1Sent = false; + this.Response1 = response1; + this.Response2 = response2; + } + + } +} diff --git a/tests/http/httpClient/Mocks/TestHttpMessageHandler.cs b/tests/http/httpClient/Mocks/TestHttpMessageHandler.cs new file mode 100644 index 00000000..74bfae7f --- /dev/null +++ b/tests/http/httpClient/Mocks/TestHttpMessageHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks +{ + public class TestHttpMessageHandler : DelegatingHandler + { + private readonly Action requestMessageDelegate; + private readonly Dictionary responseMessages; + + public TestHttpMessageHandler(Action requestMessage = null) + { + this.requestMessageDelegate = requestMessage ?? DefaultRequestHandler; + this.responseMessages = new Dictionary(); + } + + public void AddResponseMapping(string requestUrl, HttpResponseMessage responseMessage) + { + this.responseMessages.Add(requestUrl, responseMessage); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + requestMessageDelegate(request); + + if(this.responseMessages.TryGetValue(request.RequestUri.ToString(), out HttpResponseMessage responseMessage)) + { + responseMessage.RequestMessage = request; + return Task.FromResult(responseMessage); + } + + return Task.FromResult(new HttpResponseMessage()); + } + + private void DefaultRequestHandler(HttpRequestMessage httpRequest) + { + + } + } +} diff --git a/tests/http/httpClient/RequestAdapterTests.cs b/tests/http/httpClient/RequestAdapterTests.cs new file mode 100644 index 00000000..1b8ca2d5 --- /dev/null +++ b/tests/http/httpClient/RequestAdapterTests.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests +{ + public class HttpClientRequestAdapterTests + { + private readonly IAuthenticationProvider _authenticationProvider; + private readonly HttpClientRequestAdapter requestAdapter; + + public HttpClientRequestAdapterTests() + { + _authenticationProvider = new Mock().Object; + requestAdapter = new HttpClientRequestAdapter(new AnonymousAuthenticationProvider()); + } + + [Fact] + public void ThrowsArgumentNullExceptionOnNullAuthenticationProvider() + { + var exception = Assert.Throws(() => new HttpClientRequestAdapter(null)); + Assert.Equal("authenticationProvider", exception.ParamName); + } + + [Fact] + public void BaseUrlIsSetAsExpected() + { + var httpClientRequestAdapter = new HttpClientRequestAdapter(_authenticationProvider); + Assert.Null(httpClientRequestAdapter.BaseUrl);// url is null + + httpClientRequestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0"; + Assert.Equal("https://graph.microsoft.com/v1.0", httpClientRequestAdapter.BaseUrl);// url is set as expected + + httpClientRequestAdapter.BaseUrl = "https://graph.microsoft.com/v1.0/"; + Assert.Equal("https://graph.microsoft.com/v1.0", httpClientRequestAdapter.BaseUrl);// url is does not have the last `/` character + } + + [Fact] + public void BaseUrlIsSetFromHttpClient() + { + var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri("https://graph.microsoft.com/v1.0/"); + var httpClientRequestAdapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: httpClient); + + Assert.NotNull(httpClientRequestAdapter.BaseUrl);// url is not null + Assert.Equal("https://graph.microsoft.com/v1.0", httpClientRequestAdapter.BaseUrl);// url is does not have the last `/` character + } + + [Fact] + public void EnablesBackingStore() + { + // Arrange + var requestAdapter = new HttpClientRequestAdapter(_authenticationProvider); + var backingStore = new Mock().Object; + + //Assert the that we originally have an in memory backing store + Assert.IsAssignableFrom(BackingStoreFactorySingleton.Instance); + + // Act + requestAdapter.EnableBackingStore(backingStore); + + //Assert the backing store has been updated + Assert.IsAssignableFrom(backingStore.GetType(), BackingStoreFactorySingleton.Instance); + } + + + [Fact] + public async Task GetRequestMessageFromRequestInformationWithBaseUrlTemplate() + { + // Arrange + requestAdapter.BaseUrl = "http://localhost"; + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/me" + }; + + // Act + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Assert + Assert.NotNull(requestMessage.RequestUri); + Assert.Contains("http://localhost/me", requestMessage.RequestUri.OriginalString); + } + + [Fact] + public async Task GetRequestMessageFromRequestInformationUsesBaseUrlFromAdapter() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/me", + PathParameters = new Dictionary + { + { "baseurl", "https://graph.microsoft.com/beta"}//request information with different base url + } + + }; + // Change the baseUrl of the adapter + requestAdapter.BaseUrl = "http://localhost"; + + // Act + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Assert + Assert.NotNull(requestMessage.RequestUri); + Assert.Contains("http://localhost/me", requestMessage.RequestUri.OriginalString);// Request generated using adapter baseUrl + } + + [Theory] + [InlineData("select", new[] { "id", "displayName" }, "select=id,displayName")] + [InlineData("count", true, "count=true")] + [InlineData("skip", 10, "skip=10")] + [InlineData("skip", null, "")]// query parameter no placed + public async Task GetRequestMessageFromRequestInformationSetsQueryParametersCorrectlyWithSelect(string queryParam, object queryParamObject, string expectedString) + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "http://localhost/me{?top,skip,search,filter,count,orderby,select}" + }; + requestInfo.QueryParameters.Add(queryParam, queryParamObject); + + // Act + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Assert + Assert.NotNull(requestMessage.RequestUri); + Assert.Contains(expectedString, requestMessage.RequestUri.Query); + } + + [Fact] + public async Task GetRequestMessageFromRequestInformationSetsContentHeaders() + { + // Arrange + var requestInfo = new RequestInformation + { + HttpMethod = Method.PUT, + UrlTemplate = "https://sn3302.up.1drv.com/up/fe6987415ace7X4e1eF866337" + }; + requestInfo.Headers.Add("Content-Length", "26"); + requestInfo.Headers.Add("Content-Range", "bytes 0-25/128"); + requestInfo.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes("contents")), "application/octet-stream"); + + // Act + var requestMessage = await requestAdapter.ConvertToNativeRequestAsync(requestInfo); + + // Assert + Assert.NotNull(requestMessage.Content); + // Content length set correctly + Assert.Equal(26, requestMessage.Content.Headers.ContentLength); + // Content range set correctly + Assert.Equal("bytes", requestMessage.Content.Headers.ContentRange.Unit); + Assert.Equal(0, requestMessage.Content.Headers.ContentRange.From); + Assert.Equal(25, requestMessage.Content.Headers.ContentRange.To); + Assert.Equal(128, requestMessage.Content.Headers.ContentRange.Length); + Assert.True(requestMessage.Content.Headers.ContentRange.HasRange); + Assert.True(requestMessage.Content.Headers.ContentRange.HasLength); + // Content type set correctly + Assert.Equal("application/octet-stream", requestMessage.Content.Headers.ContentType.MediaType); + } + + [Fact] + public async Task SendMethodDoesNotThrowWithoutUrlTemplate() + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("Test"))) + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri("https://example.com") + }; + + var response = await adapter.SendPrimitiveAsync(requestInfo); + + Assert.True(response.CanRead); + Assert.Equal(4, response.Length); + } + + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [InlineData(HttpStatusCode.PartialContent)] + [Theory] + public async Task SendStreamReturnsUsableStream(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("Test"))) + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendPrimitiveAsync(requestInfo); + + Assert.True(response.CanRead); + Assert.Equal(4, response.Length); + var streamReader = new StreamReader(response); + var responseString = await streamReader.ReadToEndAsync(); + Assert.Equal("Test", responseString); + } + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [InlineData(HttpStatusCode.NoContent)] + [Theory] + public async Task SendStreamReturnsNullForNoContent(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendPrimitiveAsync(requestInfo); + + Assert.Null(response); + } + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [InlineData(HttpStatusCode.NoContent)] + [InlineData(HttpStatusCode.PartialContent)] + [Theory] + public async Task SendSNoContentDoesntFailOnOtherStatusCodes(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + await adapter.SendNoContentAsync(requestInfo); + } + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [InlineData(HttpStatusCode.NoContent)] + [InlineData(HttpStatusCode.ResetContent)] + [Theory] + public async Task SendReturnsNullOnNoContent(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendAsync(requestInfo, MockEntity.Factory); + + Assert.Null(response); + } + + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [InlineData(HttpStatusCode.NoContent)] + [InlineData(HttpStatusCode.ResetContent)] + [Theory] + public async Task SendReturnsNullOnNoContentWithContentHeaderPresent(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json") + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendAsync(requestInfo, MockEntity.Factory); + + Assert.Null(response); + } + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NonAuthoritativeInformation)] + [Theory] + public async Task SendReturnsObjectOnContent(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + using var mockContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("Test"))); + mockContent.Headers.ContentType = new("application/json"); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = mockContent, + }); + var mockParseNode = new Mock(); + mockParseNode.Setup(x => x.GetObjectValue(It.IsAny>())) + .Returns(new MockEntity()); + var mockParseNodeFactory = new Mock(); + mockParseNodeFactory.Setup(x => x.GetRootParseNodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(mockParseNode.Object)); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client, parseNodeFactory: mockParseNodeFactory.Object); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendAsync(requestInfo, MockEntity.Factory); + + Assert.NotNull(response); + } + [Fact] + public async Task RetriesOnCAEResponse() + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + var methodCalled = false; + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((mess, token) => + { + var response = new HttpResponseMessage + { + StatusCode = methodCalled ? HttpStatusCode.OK : HttpStatusCode.Unauthorized, + Content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("Test"))) + }; + if(!methodCalled) + response.Headers.WwwAuthenticate.Add(new("Bearer", "realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", client_id=\"00000003-0000-0000-c000-000000000000\", error=\"insufficient_claims\", claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTY1MjgxMzUwOCJ9fX0=\"")); + methodCalled = true; + return Task.FromResult(response); + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + + var response = await adapter.SendPrimitiveAsync(requestInfo); + + Assert.NotNull(response); + + mockHandler.Protected().Verify("SendAsync", Times.Exactly(2), ItExpr.IsAny(), ItExpr.IsAny()); + } + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadGateway)] + [Theory] + public async Task SetsTheApiExceptionStatusCode(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => + { + var responseMessage = new HttpResponseMessage + { + StatusCode = statusCode + }; + responseMessage.Headers.Add("request-id", "guid-value"); + return responseMessage; + }); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + try + { + var response = await adapter.SendPrimitiveAsync(requestInfo); + Assert.Fail("Expected an ApiException to be thrown"); + } + catch(ApiException e) + { + Assert.Equal((int)statusCode, e.ResponseStatusCode); + Assert.True(e.ResponseHeaders.ContainsKey("request-id")); + } + } + [InlineData(HttpStatusCode.NotFound)]// 4XX + [InlineData(HttpStatusCode.BadGateway)]// 5XX + [Theory] + public async Task SelectsTheXXXErrorMappingClassCorrectly(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => + { + var responseMessage = new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + return responseMessage; + }); + var mockParseNode = new Mock(); + mockParseNode.Setup(x => x.GetObjectValue(It.IsAny>())) + .Returns(new MockError("A general error occured")); + var mockParseNodeFactory = new Mock(); + mockParseNodeFactory.Setup(x => x.GetRootParseNodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(mockParseNode.Object)); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, mockParseNodeFactory.Object, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + try + { + var errorMapping = new Dictionary>() + { + { "XXX", (parseNode) => new MockError("A general error occured")}, + }; + var response = await adapter.SendPrimitiveAsync(requestInfo, errorMapping); + Assert.Fail("Expected an ApiException to be thrown"); + } + catch(MockError mockError) + { + Assert.Equal((int)statusCode, mockError.ResponseStatusCode); + Assert.Equal("A general error occured", mockError.Message); + } + } + [InlineData(HttpStatusCode.BadGateway)]// 5XX + [Theory] + public async Task ThrowsApiExceptionOnMissingMapping(HttpStatusCode statusCode) + { + var mockHandler = new Mock(); + var client = new HttpClient(mockHandler.Object); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => + { + var responseMessage = new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + return responseMessage; + }); + var mockParseNode = new Mock(); + mockParseNode.Setup(x => x.GetObjectValue(It.IsAny>())) + .Returns(new MockError("A general error occured: " + statusCode.ToString())); + var mockParseNodeFactory = new Mock(); + mockParseNodeFactory.Setup(x => x.GetRootParseNodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(mockParseNode.Object)); + var adapter = new HttpClientRequestAdapter(_authenticationProvider, mockParseNodeFactory.Object, httpClient: client); + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "https://example.com" + }; + try + { + var errorMapping = new Dictionary>() + { + { "4XX", (parseNode) => new MockError("A 4XX error occured") }//Only 4XX + }; + var response = await adapter.SendPrimitiveAsync(requestInfo, errorMapping); + Assert.Fail("Expected an ApiException to be thrown"); + } + catch(ApiException apiException) + { + Assert.Equal((int)statusCode, apiException.ResponseStatusCode); + Assert.Contains("The server returned an unexpected status code and no error factory is registered for this code", apiException.Message); + } + } + } +} diff --git a/tests/serialization/form/FormAsyncParseNodeFactoryTests.cs b/tests/serialization/form/FormAsyncParseNodeFactoryTests.cs new file mode 100644 index 00000000..88d451d6 --- /dev/null +++ b/tests/serialization/form/FormAsyncParseNodeFactoryTests.cs @@ -0,0 +1,45 @@ +using System.Text; + +namespace Microsoft.Kiota.Serialization.Form.Tests; + +public class FormAsyncParseNodeFactoryTests +{ + private readonly FormParseNodeFactory _formParseNodeFactory = new(); + private const string TestJsonString = "key=value"; + [Fact] + public async Task GetsWriterForFormContentType() + { + using var formStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var formParseNode = await _formParseNodeFactory.GetRootParseNodeAsync(_formParseNodeFactory.ValidContentType, formStream); + + // Assert + Assert.NotNull(formParseNode); + Assert.IsAssignableFrom(formParseNode); + } + [Fact] + public async Task ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync( + async () => await _formParseNodeFactory.GetRootParseNodeAsync(streamContentType, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_formParseNodeFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ThrowsArgumentNullExceptionForNoContentType(string? contentType) + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync( + async () => await _formParseNodeFactory.GetRootParseNodeAsync(contentType!, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } +} \ No newline at end of file diff --git a/tests/serialization/form/FormParseNodeFactoryTests.cs b/tests/serialization/form/FormParseNodeFactoryTests.cs new file mode 100644 index 00000000..65657458 --- /dev/null +++ b/tests/serialization/form/FormParseNodeFactoryTests.cs @@ -0,0 +1,43 @@ +using System.Text; + +namespace Microsoft.Kiota.Serialization.Form.Tests; + +public class FormParseNodeFactoryTests +{ + private readonly FormParseNodeFactory _formParseNodeFactory = new(); + private const string TestJsonString = "key=value"; + [Fact] + public void GetsWriterForFormContentType() + { + using var formStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var formParseNode = _formParseNodeFactory.GetRootParseNode(_formParseNodeFactory.ValidContentType, formStream); + + // Assert + Assert.NotNull(formParseNode); + Assert.IsAssignableFrom(formParseNode); + } + [Fact] + public void ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = Assert.Throws(() => _formParseNodeFactory.GetRootParseNode(streamContentType, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_formParseNodeFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ThrowsArgumentNullExceptionForNoContentType(string? contentType) + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = Assert.Throws(() => _formParseNodeFactory.GetRootParseNode(contentType!, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } +} \ No newline at end of file diff --git a/tests/serialization/form/FormParseNodeTests.cs b/tests/serialization/form/FormParseNodeTests.cs new file mode 100644 index 00000000..69971a08 --- /dev/null +++ b/tests/serialization/form/FormParseNodeTests.cs @@ -0,0 +1,130 @@ +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Serialization.Form.Tests.Mocks; + +namespace Microsoft.Kiota.Serialization.Form.Tests; +public class FormParseNodeTests +{ + private const string TestUserForm = "displayName=Megan+Bowen&" + + "numbers=one,two,thirtytwo&" + + "givenName=Megan&" + + "accountEnabled=true&" + + "createdDateTime=2017-07-29T03:07:25Z&" + + "jobTitle=Auditor&" + + "mail=MeganB@M365x214355.onmicrosoft.com&" + + "mobilePhone=null&" + + "officeLocation=null&" + + "preferredLanguage=en-US&" + + "surname=Bowen&" + + "workDuration=PT1H&" + + "startWorkTime=08:00:00.0000000&" + + "endWorkTime=17:00:00.0000000&" + + "userPrincipalName=MeganB@M365x214355.onmicrosoft.com&" + + "birthDay=2017-09-04&" + + "deviceNames=device1&deviceNames=device2&"+ //collection property + "otherPhones=123456789&otherPhones=987654321&" + //collection property for additionalData + "id=48d31887-5fad-4d73-a9f5-3c356e68a038"; + + [Fact] + public void GetsEntityValueFromForm() + { + // Arrange + var formParseNode = new FormParseNode(TestUserForm); + // Act + var testEntity = formParseNode.GetObjectValue(x => new TestEntity()); + // Assert + Assert.NotNull(testEntity); + Assert.Null(testEntity.OfficeLocation); + Assert.NotEmpty(testEntity.AdditionalData); + Assert.Equal(2, testEntity.DeviceNames?.Count);// collection is deserialized + Assert.True(testEntity.AdditionalData.ContainsKey("jobTitle")); + Assert.True(testEntity.AdditionalData.ContainsKey("mobilePhone")); + Assert.True(testEntity.AdditionalData.ContainsKey("otherPhones")); + Assert.Equal("true",testEntity.AdditionalData["accountEnabled"]); + Assert.Equal("Auditor", testEntity.AdditionalData["jobTitle"]); + Assert.Equal("123456789,987654321", testEntity.AdditionalData["otherPhones"]); + Assert.Equal("48d31887-5fad-4d73-a9f5-3c356e68a038", testEntity.Id); + Assert.Equal(TestEnum.One | TestEnum.Two, testEntity.Numbers ); // Unknown enum value is not included + Assert.Equal(TimeSpan.FromHours(1), testEntity.WorkDuration); // Parses timespan values + Assert.Equal(new Time(8,0,0).ToString(),testEntity.StartWorkTime.ToString());// Parses time values + Assert.Equal(new Time(17, 0, 0).ToString(), testEntity.EndWorkTime.ToString());// Parses time values + Assert.Equal(new Date(2017,9,4).ToString(), testEntity.BirthDay.ToString());// Parses date values + } + + [Fact] + public void GetCollectionOfNumberPrimitiveValuesFromForm() + { + string TestFormData = "numbers=1&" + + "numbers=2&" + + "numbers=3&"; + var formParseNode = new FormParseNode(TestFormData); + var numberNode = formParseNode.GetChildNode("numbers"); + var numberCollection = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollection); + Assert.Equal(3, numberCollection.Count()); + Assert.Equal(1, numberCollection.First()); + var numberCollectionAsStrings = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollectionAsStrings); + Assert.Equal(3, numberCollectionAsStrings.Count()); + Assert.Equal("1", numberCollectionAsStrings.First()); + var numberCollectionAsShort = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollectionAsShort); + Assert.Equal(3, numberCollectionAsShort.Count()); + Assert.Equal((sbyte)1, numberCollectionAsShort.First()); + var numberCollectionAsDouble = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollectionAsDouble); + Assert.Equal(3, numberCollectionAsDouble.Count()); + Assert.Equal((double)1, numberCollectionAsDouble.First()); + var numberCollectionAsFloat = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollectionAsFloat); + Assert.Equal(3, numberCollectionAsFloat.Count()); + Assert.Equal((float)1, numberCollectionAsFloat.First()); + var numberCollectionAsDecimal = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollectionAsDecimal); + Assert.Equal(3, numberCollectionAsDecimal.Count()); + Assert.Equal((decimal)1, numberCollectionAsDecimal.First()); + } + + [Fact] + public void GetCollectionOfBooleanPrimitiveValuesFromForm() + { + string TestFormData = "bools=true&" + + "bools=false"; + var formParseNode = new FormParseNode(TestFormData); + var numberNode = formParseNode.GetChildNode("bools"); + var numberCollection = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollection); + Assert.Equal(2, numberCollection.Count()); + Assert.Equal(true, numberCollection.First()); + } + + [Fact] + public void GetCollectionOfGuidPrimitiveValuesFromForm() + { + string TestFormData = "ids=48d31887-5fad-4d73-a9f5-3c356e68a038&" + + "ids=48d31887-5fad-4d73-a9f5-3c356e68a038"; + var formParseNode = new FormParseNode(TestFormData); + var numberNode = formParseNode.GetChildNode("ids"); + var numberCollection = numberNode?.GetCollectionOfPrimitiveValues(); + Assert.NotNull(numberCollection); + Assert.Equal(2, numberCollection.Count()); + Assert.Equal(Guid.Parse("48d31887-5fad-4d73-a9f5-3c356e68a038"), numberCollection.First()); + } + + [Fact] + public void GetCollectionOfObjectValuesFromForm() + { + var formParseNode = new FormParseNode(TestUserForm); + Assert.Throws(() => formParseNode.GetCollectionOfObjectValues(static x => new TestEntity())); + } + + [Fact] + public void ReturnsDefaultIfChildNodeDoesNotExist() + { + // Arrange + var rootParseNode = new FormParseNode(TestUserForm); + // Try to get an imaginary node value + var imaginaryNode = rootParseNode.GetChildNode("imaginaryNode"); + // Assert + Assert.Null(imaginaryNode); + } +} diff --git a/tests/serialization/form/FormSerializationWriterFactoryTests.cs b/tests/serialization/form/FormSerializationWriterFactoryTests.cs new file mode 100644 index 00000000..0d348eae --- /dev/null +++ b/tests/serialization/form/FormSerializationWriterFactoryTests.cs @@ -0,0 +1,38 @@ +namespace Microsoft.Kiota.Serialization.Form.Tests; +public class FormSerializationWriterFactoryTests +{ + private readonly FormSerializationWriterFactory _formSerializationFactory = new(); + + [Fact] + public void GetsWriterForFormContentType() + { + var formWriter = _formSerializationFactory.GetSerializationWriter(_formSerializationFactory.ValidContentType); + + // Assert + Assert.NotNull(formWriter); + Assert.IsAssignableFrom(formWriter); + } + + [Fact] + public void ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + var exception = Assert.Throws(() => _formSerializationFactory.GetSerializationWriter(streamContentType)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_formSerializationFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ThrowsArgumentNullExceptionForNoContentType(string? contentType) + { + var exception = Assert.Throws(() => _formSerializationFactory.GetSerializationWriter(contentType!)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } +} diff --git a/tests/serialization/form/FormSerializationWriterTests.cs b/tests/serialization/form/FormSerializationWriterTests.cs new file mode 100644 index 00000000..e4d4d480 --- /dev/null +++ b/tests/serialization/form/FormSerializationWriterTests.cs @@ -0,0 +1,111 @@ +using System.Text; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Serialization.Form.Tests.Mocks; + +namespace Microsoft.Kiota.Serialization.Form.Tests; +public class FormSerializationWriterTests +{ + [Fact] + public void WritesSampleObjectValue() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + Numbers = TestEnum.One | TestEnum.Two, + DeviceNames = new List + { + "device1", "device2" + }, + AdditionalData = new Dictionary + { + {"mobilePhone", null!}, // write null value + {"accountEnabled", false}, // write bool value + {"jobTitle","Author"}, // write string value + {"otherPhones", new List{ "123456789", "987654321"} }, + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + { "decimalValue", 2m}, + { "floatValue", 1.2f}, + { "longValue", 2L}, + { "doubleValue", 2d}, + { "guidValue", Guid.Parse("48d31887-5fad-4d73-a9f5-3c356e68a038")}, + { "intValue", 1} + } + }; + using var formSerializerWriter = new FormSerializationWriter(); + // Act + formSerializerWriter.WriteObjectValue(string.Empty,testEntity); + // Get the string from the stream. + var serializedStream = formSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedFormString = reader.ReadToEnd(); + + // Assert + var expectedString = "id=48d31887-5fad-4d73-a9f5-3c356e68a038&" + + "numbers=one%2Ctwo&"+ // serializes enums + "workDuration=PT1H&"+ // Serializes timespans + "birthDay=2017-09-04&" + // Serializes dates + "startWorkTime=08%3A00%3A00&" + //Serializes times + "deviceNames=device1&deviceNames=device2&" + // Serializes collection of scalars using the same key + "mobilePhone=null&" + // Serializes null values + "accountEnabled=false&" + + "jobTitle=Author&" + + "otherPhones=123456789&otherPhones=987654321&" + // Serializes collection of scalars using the same key which we present in the AdditionalData + "createdDateTime=0001-01-01T00%3A00%3A00.0000000%2B00%3A00&" + + "decimalValue=2&" + + "floatValue=1.2&" + + "longValue=2&" + + "doubleValue=2&" + + "guidValue=48d31887-5fad-4d73-a9f5-3c356e68a038&" + + "intValue=1"; + Assert.Equal(expectedString, serializedFormString); + } + + [Fact] + public void DoesNotWritesSampleCollectionOfObjectValues() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + Numbers = TestEnum.One | TestEnum.Two, + AdditionalData = new Dictionary + { + {"mobilePhone",null!}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + } + }; + var entityList = new List() { testEntity }; + using var formSerializerWriter = new FormSerializationWriter(); + // Act + var exception = Assert.Throws(() => formSerializerWriter.WriteCollectionOfObjectValues(string.Empty, entityList)); + Assert.Equal("Form serialization does not support collections.", exception.Message); + } + + [Fact] + public void DoesNotWriteNestedObjectValuesInAdditionalData() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + Numbers = TestEnum.One | TestEnum.Two, + AdditionalData = new Dictionary + { + {"nestedEntity", new TestEntity() + { + Id = new Guid().ToString(), + }} // write nested entity + } + }; + using var formSerializerWriter = new FormSerializationWriter(); + // Act + var exception = Assert.Throws(() => formSerializerWriter.WriteObjectValue(string.Empty, testEntity)); + Assert.Equal("Form serialization does not support nested objects.",exception.Message); + } +} diff --git a/tests/serialization/form/Microsoft.Kiota.Serialization.Form.Tests.csproj b/tests/serialization/form/Microsoft.Kiota.Serialization.Form.Tests.csproj new file mode 100644 index 00000000..dfedea5e --- /dev/null +++ b/tests/serialization/form/Microsoft.Kiota.Serialization.Form.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net462 + true + true + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/serialization/form/Mocks/TestEntity.cs b/tests/serialization/form/Mocks/TestEntity.cs new file mode 100644 index 00000000..6d1f2fc1 --- /dev/null +++ b/tests/serialization/form/Mocks/TestEntity.cs @@ -0,0 +1,72 @@ +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Form.Tests.Mocks; +public class TestEntity : IParsable, IAdditionalDataHolder +{ + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// Read-only. + public string? Id { get; set; } + /// Read-only. + public List? DeviceNames { get; set; } + /// Read-only. + public TestEnum? Numbers { get; set; } + /// Read-only. + public TimeSpan? WorkDuration { get; set; } + /// Read-only. + public Date? BirthDay { get; set; } + /// Read-only. + public Time? StartWorkTime { get; set; } + /// Read-only. + public Time? EndWorkTime { get; set; } + /// Read-only. + public DateTimeOffset? CreatedDateTime { get; set; } + /// Read-only. + public string? OfficeLocation { get; set; } + /// + /// Instantiates a new entity and sets the default values. + /// + public TestEntity() + { + AdditionalData = new Dictionary(); + } + /// + /// The deserialization information for the current model + /// + public IDictionary> GetFieldDeserializers() + { + return new Dictionary> { + {"id", n => { Id = n.GetStringValue(); } }, + {"numbers", n => { Numbers = n.GetEnumValue(); } }, + {"createdDateTime", n => { CreatedDateTime = n.GetDateTimeOffsetValue(); } }, + {"officeLocation", n => { OfficeLocation = n.GetStringValue(); } }, + {"workDuration", n => { WorkDuration = n.GetTimeSpanValue(); } }, + {"birthDay", n => { BirthDay = n.GetDateValue(); } }, + {"startWorkTime", n => { StartWorkTime = n.GetTimeValue(); } }, + {"endWorkTime", n => { EndWorkTime = n.GetTimeValue(); } }, + {"deviceNames", n => { DeviceNames = n.GetCollectionOfPrimitiveValues().ToList(); } }, + }; + } + /// + /// Serializes information the current object + /// Serialization writer to use to serialize this model + /// + public void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteEnumValue("numbers",Numbers); + writer.WriteDateTimeOffsetValue("createdDateTime", CreatedDateTime); + writer.WriteStringValue("officeLocation", OfficeLocation); + writer.WriteTimeSpanValue("workDuration", WorkDuration); + writer.WriteDateValue("birthDay", BirthDay); + writer.WriteTimeValue("startWorkTime", StartWorkTime); + writer.WriteTimeValue("endWorkTime", EndWorkTime); + writer.WriteCollectionOfPrimitiveValues("deviceNames", DeviceNames); + writer.WriteAdditionalData(AdditionalData); + } + public static TestEntity CreateFromDiscriminator(IParseNode parseNode) { + return new TestEntity(); + } +} diff --git a/tests/serialization/form/Mocks/TestEnum.cs b/tests/serialization/form/Mocks/TestEnum.cs new file mode 100644 index 00000000..0222b96f --- /dev/null +++ b/tests/serialization/form/Mocks/TestEnum.cs @@ -0,0 +1,10 @@ +namespace Microsoft.Kiota.Serialization.Form.Tests.Mocks; +[Flags] +public enum TestEnum +{ + One = 0x00000001, + Two = 0x00000002, + Four = 0x00000004, + Eight = 0x00000008, + Sixteen = 0x00000010 +} diff --git a/tests/serialization/form/Usings.cs b/tests/serialization/form/Usings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/tests/serialization/form/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/serialization/json/Converters/JsonGuidConverter.cs b/tests/serialization/json/Converters/JsonGuidConverter.cs new file mode 100644 index 00000000..de87e702 --- /dev/null +++ b/tests/serialization/json/Converters/JsonGuidConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Converters; + +/// +/// Converts a GUID object or value to/from JSON. +/// +public class JsonGuidConverter : JsonConverter +{ + /// + public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.Null + ? Guid.Empty + : ReadInternal(ref reader); + + /// + public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) + => WriteInternal(writer, value); + + private static Guid ReadInternal(ref Utf8JsonReader reader) + => Guid.Parse(reader.GetString()!); + + private static void WriteInternal(Utf8JsonWriter writer, Guid value) + => writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture)); +} \ No newline at end of file diff --git a/tests/serialization/json/IntersectionWrapperParseTests.cs b/tests/serialization/json/IntersectionWrapperParseTests.cs new file mode 100644 index 00000000..23c74dcb --- /dev/null +++ b/tests/serialization/json/IntersectionWrapperParseTests.cs @@ -0,0 +1,180 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Kiota.Serialization.Json.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests; + +public class IntersectionWrapperParseTests { + private readonly JsonParseNodeFactory _parseNodeFactory = new(); + private readonly JsonSerializationWriterFactory _serializationWriterFactory = new(); + private const string contentType = "application/json"; + [Fact] + public async Task ParsesIntersectionTypeComplexProperty1() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("{\"displayName\":\"McGill\",\"officeLocation\":\"Montreal\", \"id\": \"opaque\"}")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(IntersectionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.NotNull(result.ComposedType1); + Assert.NotNull(result.ComposedType2); + Assert.Null(result.ComposedType3); + Assert.Null(result.StringValue); + Assert.Equal("opaque", result.ComposedType1.Id); + Assert.Equal("McGill", result.ComposedType2.DisplayName); + } + [Fact] + public async Task ParsesIntersectionTypeComplexProperty2() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("{\"displayName\":\"McGill\",\"officeLocation\":\"Montreal\", \"id\": 10}")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(IntersectionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.NotNull(result.ComposedType1); + Assert.NotNull(result.ComposedType2); + Assert.Null(result.ComposedType3); + Assert.Null(result.StringValue); + Assert.Null(result.ComposedType1.Id); + Assert.Null(result.ComposedType2.Id); // it's expected to be null since we have conflicting properties here and the parser will only try one to avoid having to brute its way through + Assert.Equal("McGill", result.ComposedType2.DisplayName); + } + [Fact] + public async Task ParsesIntersectionTypeComplexProperty3() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("[{\"@odata.type\":\"#microsoft.graph.TestEntity\",\"officeLocation\":\"Ottawa\", \"id\": \"11\"}, {\"@odata.type\":\"#microsoft.graph.TestEntity\",\"officeLocation\":\"Montreal\", \"id\": \"10\"}]")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(IntersectionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.Null(result.ComposedType1); + Assert.Null(result.ComposedType2); + Assert.NotNull(result.ComposedType3); + Assert.Null(result.StringValue); + Assert.Equal(2, result.ComposedType3.Count); + Assert.Equal("Ottawa", result.ComposedType3.First().OfficeLocation); + } + [Fact] + public async Task ParsesIntersectionTypeStringValue() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("\"officeLocation\"")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(IntersectionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.Null(result.ComposedType2); + Assert.Null(result.ComposedType1); + Assert.Null(result.ComposedType3); + Assert.Equal("officeLocation", result.StringValue); + } + [Fact] + public void SerializesIntersectionTypeStringValue() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new IntersectionTypeMock { + StringValue = "officeLocation" + }; + + // When + model.Serialize(writer); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("\"officeLocation\"", result); + } + [Fact] + public void SerializesIntersectionTypeComplexProperty1() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new IntersectionTypeMock { + ComposedType1 = new() { + Id = "opaque", + OfficeLocation = "Montreal", + }, + ComposedType2 = new() { + DisplayName = "McGill", + }, + }; + + // When + model.Serialize(writer); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("{\"id\":\"opaque\",\"officeLocation\":\"Montreal\",\"displayName\":\"McGill\"}", result); + } + [Fact] + public void SerializesIntersectionTypeComplexProperty2() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new IntersectionTypeMock { + ComposedType2 = new() { + DisplayName = "McGill", + Id = 10, + }, + }; + + // When + model.Serialize(writer); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("{\"displayName\":\"McGill\",\"id\":10}", result); + } + + [Fact] + public void SerializesIntersectionTypeComplexProperty3() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new IntersectionTypeMock { + ComposedType3 = new() { + new() { + OfficeLocation = "Montreal", + Id = "10", + }, + new() { + OfficeLocation = "Ottawa", + Id = "11", + } + }, + }; + + // When + model.Serialize(writer); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("[{\"id\":\"10\",\"officeLocation\":\"Montreal\"},{\"id\":\"11\",\"officeLocation\":\"Ottawa\"}]", result); + } +} \ No newline at end of file diff --git a/tests/serialization/json/JsonAsyncParseNodeFactoryTests.cs b/tests/serialization/json/JsonAsyncParseNodeFactoryTests.cs new file mode 100644 index 00000000..9005719a --- /dev/null +++ b/tests/serialization/json/JsonAsyncParseNodeFactoryTests.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests +{ + public class JsonAsyncParseNodeFactoryTests + { + private readonly JsonParseNodeFactory _jsonParseNodeFactory; + private const string TestJsonString = "{\"key\":\"value\"}"; + + public JsonAsyncParseNodeFactoryTests() + { + _jsonParseNodeFactory = new JsonParseNodeFactory(); + } + + [Fact] + public async Task GetsWriterForJsonContentType() + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var jsonWriter = await _jsonParseNodeFactory.GetRootParseNodeAsync(_jsonParseNodeFactory.ValidContentType, jsonStream); + + // Assert + Assert.NotNull(jsonWriter); + Assert.IsAssignableFrom(jsonWriter); + } + + [Fact] + public async Task ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync( + async () => await _jsonParseNodeFactory.GetRootParseNodeAsync(streamContentType, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_jsonParseNodeFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ThrowsArgumentNullExceptionForNoContentType(string contentType) + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync( + async () => await _jsonParseNodeFactory.GetRootParseNodeAsync(contentType, jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } + } +} diff --git a/tests/serialization/json/JsonParseNodeFactoryTests.cs b/tests/serialization/json/JsonParseNodeFactoryTests.cs new file mode 100644 index 00000000..fdc135d7 --- /dev/null +++ b/tests/serialization/json/JsonParseNodeFactoryTests.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests +{ + public class JsonParseNodeFactoryTests + { + private readonly JsonParseNodeFactory _jsonParseNodeFactory; + private const string TestJsonString = "{\"key\":\"value\"}"; + + public JsonParseNodeFactoryTests() + { + _jsonParseNodeFactory = new JsonParseNodeFactory(); + } + + [Fact] + public async Task GetsWriterForJsonContentType() + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var jsonWriter = await _jsonParseNodeFactory.GetRootParseNodeAsync(_jsonParseNodeFactory.ValidContentType,jsonStream); + + // Assert + Assert.NotNull(jsonWriter); + Assert.IsAssignableFrom(jsonWriter); + } + + [Fact] + public async Task ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync(async () => await _jsonParseNodeFactory.GetRootParseNodeAsync(streamContentType,jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_jsonParseNodeFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ThrowsArgumentNullExceptionForNoContentType(string contentType) + { + using var jsonStream = new MemoryStream(Encoding.UTF8.GetBytes(TestJsonString)); + var exception = await Assert.ThrowsAsync(async () => await _jsonParseNodeFactory.GetRootParseNodeAsync(contentType,jsonStream)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } + } +} diff --git a/tests/serialization/json/JsonParseNodeTests.cs b/tests/serialization/json/JsonParseNodeTests.cs new file mode 100644 index 00000000..05c82645 --- /dev/null +++ b/tests/serialization/json/JsonParseNodeTests.cs @@ -0,0 +1,291 @@ +using System; +using System.Linq; +using System.Text.Json; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Serialization.Json.Tests.Converters; +using Microsoft.Kiota.Serialization.Json.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests +{ + public class JsonParseNodeTests + { + private const string TestUserJson = "{\r\n" + + " \"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#users/$entity\",\r\n" + + " \"@odata.id\": \"https://graph.microsoft.com/v2/dcd219dd-bc68-4b9b-bf0b-4a33a796be35/directoryObjects/48d31887-5fad-4d73-a9f5-3c356e68a038/Microsoft.DirectoryServices.User\",\r\n" + + " \"businessPhones\": [\r\n" + + " \"+1 412 555 0109\"\r\n" + + " ],\r\n" + + " \"displayName\": \"Megan Bowen\",\r\n" + + " \"numbers\":\"one,two,thirtytwo\"," + + " \"testNamingEnum\":\"Item2:SubItem1\"," + + " \"givenName\": \"Megan\",\r\n" + + " \"accountEnabled\": true,\r\n" + + " \"createdDateTime\": \"2017 -07-29T03:07:25Z\",\r\n" + + " \"jobTitle\": \"Auditor\",\r\n" + + " \"mail\": \"MeganB@M365x214355.onmicrosoft.com\",\r\n" + + " \"mobilePhone\": null,\r\n" + + " \"officeLocation\": null,\r\n" + + " \"preferredLanguage\": \"en-US\",\r\n" + + " \"surname\": \"Bowen\",\r\n" + + " \"workDuration\": \"PT1H\",\r\n" + + " \"startWorkTime\": \"08:00:00.0000000\",\r\n" + + " \"endWorkTime\": \"17:00:00.0000000\",\r\n" + + " \"userPrincipalName\": \"MeganB@M365x214355.onmicrosoft.com\",\r\n" + + " \"birthDay\": \"2017-09-04\",\r\n" + + " \"id\": \"48d31887-5fad-4d73-a9f5-3c356e68a038\"\r\n" + + "}"; + private const string TestStudentJson = "{\r\n" + + " \"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#users/$entity\",\r\n" + + " \"@odata.type\": \"microsoft.graph.student\",\r\n" + + " \"@odata.id\": \"https://graph.microsoft.com/v2/dcd219dd-bc68-4b9b-bf0b-4a33a796be35/directoryObjects/48d31887-5fad-4d73-a9f5-3c356e68a038/Microsoft.DirectoryServices.User\",\r\n" + + " \"businessPhones\": [\r\n" + + " \"+1 412 555 0109\"\r\n" + + " ],\r\n" + + " \"displayName\": \"Megan Bowen\",\r\n" + + " \"numbers\":\"one,two,thirtytwo\"," + + " \"testNamingEnum\":\"Item2:SubItem1\"," + + " \"givenName\": \"Megan\",\r\n" + + " \"accountEnabled\": true,\r\n" + + " \"createdDateTime\": \"2017 -07-29T03:07:25Z\",\r\n" + + " \"jobTitle\": \"Auditor\",\r\n" + + " \"mail\": \"MeganB@M365x214355.onmicrosoft.com\",\r\n" + + " \"mobilePhone\": null,\r\n" + + " \"officeLocation\": null,\r\n" + + " \"preferredLanguage\": \"en-US\",\r\n" + + " \"surname\": \"Bowen\",\r\n" + + " \"workDuration\": \"PT1H\",\r\n" + + " \"startWorkTime\": \"08:00:00.0000000\",\r\n" + + " \"endWorkTime\": \"17:00:00.0000000\",\r\n" + + " \"userPrincipalName\": \"MeganB@M365x214355.onmicrosoft.com\",\r\n" + + " \"birthDay\": \"2017-09-04\",\r\n" + + " \"enrolmentDate\": \"2017-09-04\",\r\n" + + " \"id\": \"48d31887-5fad-4d73-a9f5-3c356e68a038\"\r\n" + + "}"; + + private const string TestUntypedJson = "{\r\n" + + " \"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#sites('contoso.sharepoint.com')/lists('fa631c4d-ac9f-4884-a7f5-13c659d177e3')/items('1')/fields/$entity\",\r\n" + + " \"id\": \"5\",\r\n" + + " \"title\": \"Project 101\",\r\n" + + " \"location\": {\r\n" + + " \"address\": {\r\n" + + " \"city\": \"Redmond\",\r\n" + + " \"postalCode\": \"98052\",\r\n" + + " \"state\": \"Washington\",\r\n" + + " \"street\": \"NE 36th St\"\r\n" + + " },\r\n" + + " \"coordinates\": {\r\n" + + " \"latitude\": 47.641942,\r\n" + + " \"longitude\": -122.127222\r\n" + + " },\r\n" + + " \"displayName\": \"Microsoft Building 92\",\r\n" + + " \"floorCount\": 50,\r\n" + + " \"hasReception\": true,\r\n" + + " \"contact\": null\r\n" + + " },\r\n" + + " \"keywords\": [\r\n" + + " {\r\n" + + " \"created\": \"2023-07-26T10:41:26Z\",\r\n" + + " \"label\": \"Keyword1\",\r\n" + + " \"termGuid\": \"10e9cc83-b5a4-4c8d-8dab-4ada1252dd70\",\r\n" + + " \"wssId\": 6442450942\r\n" + + " },\r\n" + + " {\r\n" + + " \"created\": \"2023-07-26T10:51:26Z\",\r\n" + + " \"label\": \"Keyword2\",\r\n" + + " \"termGuid\": \"2cae6c6a-9bb8-4a78-afff-81b88e735fef\",\r\n" + + " \"wssId\": 6442450943\r\n" + + " }\r\n" + + " ],\r\n" + + " \"detail\": null,\r\n" + + " \"table\": [[1,2,3],[4,5,6],[7,8,9]],\r\n" + + " \"extra\": {\r\n" + + " \"createdDateTime\":\"2024-01-15T00:00:00\\u002B00:00\"\r\n" + + " }\r\n" + + "}"; + + private static readonly string TestUserCollectionString = $"[{TestUserJson}]"; + + [Fact] + public void GetsEntityValueFromJson() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestUserJson); + var jsonParseNode = new JsonParseNode(jsonDocument.RootElement); + // Act + var testEntity = jsonParseNode.GetObjectValue(TestEntity.CreateFromDiscriminator); + // Assert + Assert.NotNull(testEntity); + Assert.Null(testEntity.OfficeLocation); + Assert.NotEmpty(testEntity.AdditionalData); + Assert.True(testEntity.AdditionalData.ContainsKey("jobTitle")); + Assert.True(testEntity.AdditionalData.ContainsKey("mobilePhone")); + Assert.Equal("Auditor", testEntity.AdditionalData["jobTitle"]); + Assert.Equal("48d31887-5fad-4d73-a9f5-3c356e68a038", testEntity.Id); + Assert.Equal(TestEnum.One | TestEnum.Two, testEntity.Numbers ); // Unknown enum value is not included + Assert.Equal(TestNamingEnum.Item2SubItem1, testEntity.TestNamingEnum ); // correct value is chosen + Assert.Equal(TimeSpan.FromHours(1), testEntity.WorkDuration); // Parses timespan values + Assert.Equal(new Time(8,0,0).ToString(),testEntity.StartWorkTime.ToString());// Parses time values + Assert.Equal(new Time(17, 0, 0).ToString(), testEntity.EndWorkTime.ToString());// Parses time values + Assert.Equal(new Date(2017,9,4).ToString(), testEntity.BirthDay.ToString());// Parses date values + } + [Fact] + public void GetsFieldFromDerivedType() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestStudentJson); + var jsonParseNode = new JsonParseNode(jsonDocument.RootElement); + // Act + var testEntity = jsonParseNode.GetObjectValue(TestEntity.CreateFromDiscriminator) as DerivedTestEntity; + // Assert + Assert.NotNull(testEntity); + Assert.NotNull(testEntity.EnrolmentDate); + } + + [Fact] + public void GetCollectionOfObjectValuesFromJson() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestUserCollectionString); + var jsonParseNode = new JsonParseNode(jsonDocument.RootElement); + // Act + var testEntityCollection = jsonParseNode.GetCollectionOfObjectValues(x => new TestEntity()).ToArray(); + // Assert + Assert.NotEmpty(testEntityCollection); + Assert.Equal("48d31887-5fad-4d73-a9f5-3c356e68a038", testEntityCollection[0].Id); + } + + [Fact] + public void GetsChildNodeAndGetCollectionOfPrimitiveValuesFromJsonParseNode() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestUserJson); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement); + // Act to get business phones list + var phonesListChildNode = rootParseNode.GetChildNode("businessPhones"); + var phonesList = phonesListChildNode.GetCollectionOfPrimitiveValues().ToArray(); + // Assert + Assert.NotEmpty(phonesList); + Assert.Equal("+1 412 555 0109", phonesList[0]); + } + + [Fact] + public void ReturnsDefaultIfChildNodeDoesNotExist() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestUserJson); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement); + // Try to get an imaginary node value + var imaginaryNode = rootParseNode.GetChildNode("imaginaryNode"); + // Assert + Assert.Null(imaginaryNode); + } + + [Fact] + public void ParseGuidWithConverter() + { + // Arrange + var id = Guid.NewGuid(); + var json = $"{{\"id\": \"{id:N}\"}}"; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General) + { + Converters = { new JsonGuidConverter() } + }; + var serializationContext = new KiotaJsonSerializationContext(serializerOptions); + using var jsonDocument = JsonDocument.Parse(json); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement, serializationContext); + + // Act + var entity = rootParseNode.GetObjectValue(_ => new ConverterTestEntity()); + + // Assert + Assert.Equal(id, entity.Id); + } + + [Fact] + public void ParseGuidWithoutConverter() + { + // Arrange + var id = Guid.NewGuid(); + var json = $"{{\"id\": \"{id:D}\"}}"; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General); + var serializationContext = new KiotaJsonSerializationContext(serializerOptions); + using var jsonDocument = JsonDocument.Parse(json); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement, serializationContext); + + // Act + var entity = rootParseNode.GetObjectValue(_ => new ConverterTestEntity()); + + // Assert + Assert.Equal(id, entity.Id); + } + + [Fact] + public void ParseGuidEmptyString() + { + // Arrange + var json = $"{{\"id\": \"\"}}"; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General); + var serializationContext = new KiotaJsonSerializationContext(serializerOptions); + using var jsonDocument = JsonDocument.Parse(json); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement, serializationContext); + + // Act + var entity = rootParseNode.GetObjectValue(_ => new ConverterTestEntity()); + + // Assert + Assert.Null(entity.Id); + } + + [Fact] + public void GetEntityWithUntypedNodesFromJson() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(TestUntypedJson); + var rootParseNode = new JsonParseNode(jsonDocument.RootElement); + // Act + var entity = rootParseNode.GetObjectValue(UntypedTestEntity.CreateFromDiscriminatorValue); + // Assert + Assert.NotNull(entity); + Assert.Equal("5", entity.Id); + Assert.Equal("Project 101", entity.Title); + Assert.NotNull(entity.Location); + Assert.IsType(entity.Location); // creates untyped object + var location = (UntypedObject)entity.Location; + var locationProperties = location.GetValue(); + Assert.IsType(locationProperties["address"]); + Assert.IsType(locationProperties["displayName"]); // creates untyped string + Assert.IsType(locationProperties["floorCount"]); // creates untyped number + Assert.IsType(locationProperties["hasReception"]); // creates untyped boolean + Assert.IsType(locationProperties["contact"]); // creates untyped null + Assert.IsType(locationProperties["coordinates"]); // creates untyped null + var coordinates = (UntypedObject)locationProperties["coordinates"]; + var coordinatesProperties = coordinates.GetValue(); + Assert.IsType(coordinatesProperties["latitude"]); // creates untyped decimal + Assert.IsType(coordinatesProperties["longitude"]); + Assert.Equal("Microsoft Building 92", ((UntypedString)locationProperties["displayName"]).GetValue()); + Assert.Equal(50, ((UntypedInteger)locationProperties["floorCount"]).GetValue()); + Assert.True(((UntypedBoolean)locationProperties["hasReception"]).GetValue()); + Assert.Null(((UntypedNull)locationProperties["contact"]).GetValue()); + Assert.NotNull(entity.Keywords); + Assert.IsType(entity.Keywords); // creates untyped array + Assert.Equal(2, ((UntypedArray)entity.Keywords).GetValue().Count()); + Assert.Null(entity.Detail); + var extra = entity.AdditionalData["extra"]; + Assert.NotNull(extra); + Assert.NotNull(entity.Table); + var table = (UntypedArray)entity.Table;// the table is a collection + foreach(var value in table.GetValue()) + { + var row = (UntypedArray)value; + Assert.NotNull(row);// The values are a nested collection + foreach(var item in row.GetValue()) + { + var rowItem = (UntypedInteger)item; + Assert.NotNull(rowItem);// The values are a nested collection + } + } + } + } +} diff --git a/tests/serialization/json/JsonSerializationWriterFactoryTests.cs b/tests/serialization/json/JsonSerializationWriterFactoryTests.cs new file mode 100644 index 00000000..091cefff --- /dev/null +++ b/tests/serialization/json/JsonSerializationWriterFactoryTests.cs @@ -0,0 +1,48 @@ +using System; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests +{ + public class JsonSerializationWriterFactoryTests + { + private readonly JsonSerializationWriterFactory _jsonSerializationFactory; + + public JsonSerializationWriterFactoryTests() + { + _jsonSerializationFactory = new JsonSerializationWriterFactory(); + } + + [Fact] + public void GetsWriterForJsonContentType() + { + var jsonWriter = _jsonSerializationFactory.GetSerializationWriter(_jsonSerializationFactory.ValidContentType); + + // Assert + Assert.NotNull(jsonWriter); + Assert.IsAssignableFrom(jsonWriter); + } + + [Fact] + public void ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + var exception = Assert.Throws(() => _jsonSerializationFactory.GetSerializationWriter(streamContentType)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_jsonSerializationFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ThrowsArgumentNullExceptionForNoContentType(string contentType) + { + var exception = Assert.Throws(() => _jsonSerializationFactory.GetSerializationWriter(contentType)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } + } +} diff --git a/tests/serialization/json/JsonSerializationWriterTests.cs b/tests/serialization/json/JsonSerializationWriterTests.cs new file mode 100644 index 00000000..d40ce4f6 --- /dev/null +++ b/tests/serialization/json/JsonSerializationWriterTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Serialization.Json.Tests.Converters; +using Microsoft.Kiota.Serialization.Json.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests +{ + public class JsonSerializationWriterTests + { + [Fact] + public void WritesSampleObjectValue() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + HeightInMetres = 1.80m, + AdditionalData = new Dictionary + { + {"mobilePhone",null}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"weightInKgs", 51.80m}, // write weigth + {"businessPhones", new List() {"+1 412 555 0109"}}, // write collection of primitives value + {"endDateTime", new DateTime(2023,03,14,0,0,0,DateTimeKind.Utc) }, // ensure the DateTime doesn't crash + {"manager", new TestEntity{Id = "48d31887-5fad-4d73-a9f5-3c356e68a038"}}, // write nested object value + } + }; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteObjectValue(string.Empty,testEntity); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "{" + + "\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"," + + "\"workDuration\":\"PT1H\","+ // Serializes timespans + "\"birthDay\":\"2017-09-04\"," + // Serializes dates + "\"heightInMetres\":1.80,"+ + "\"startWorkTime\":\"08:00:00\"," + //Serializes times + "\"mobilePhone\":null," + + "\"accountEnabled\":false," + + "\"jobTitle\":\"Author\"," + + "\"createdDateTime\":\"0001-01-01T00:00:00+00:00\"," + + "\"weightInKgs\":51.80,"+ + "\"businessPhones\":[\"\\u002B1 412 555 0109\"]," + + "\"endDateTime\":\"2023-03-14T00:00:00+00:00\"," + + "\"manager\":{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}" + + "}"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WritesSampleObjectValueWithJsonElementAdditionalData() + { + var nullJsonElement = JsonDocument.Parse("null").RootElement; + var arrayJsonElement = JsonDocument.Parse("[\"+1 412 555 0109\"]").RootElement; + var objectJsonElement = JsonDocument.Parse("{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}").RootElement; + + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + AdditionalData = new Dictionary + { + {"mobilePhone", nullJsonElement}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"businessPhones", arrayJsonElement }, // write collection of primitives value + {"manager", objectJsonElement }, // write nested object value + } + }; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteObjectValue(string.Empty, testEntity); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "{" + + "\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"," + + "\"workDuration\":\"PT1H\"," + // Serializes timespans + "\"birthDay\":\"2017-09-04\"," + // Serializes dates + "\"startWorkTime\":\"08:00:00\"," + //Serializes times + "\"mobilePhone\":null," + + "\"accountEnabled\":false," + + "\"jobTitle\":\"Author\"," + + "\"createdDateTime\":\"0001-01-01T00:00:00+00:00\"," + + "\"businessPhones\":[\"\\u002B1 412 555 0109\"]," + + "\"manager\":{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}" + + "}"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WritesSampleCollectionOfObjectValues() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + Numbers = TestEnum.One | TestEnum.Two, + TestNamingEnum = TestNamingEnum.Item2SubItem1, + AdditionalData = new Dictionary + { + {"mobilePhone",null}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"businessPhones", new List() {"+1 412 555 0109"}}, // write collection of primitives value + {"manager", new TestEntity{Id = "48d31887-5fad-4d73-a9f5-3c356e68a038"}}, // write nested object value + } + }; + var entityList = new List() { testEntity}; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteCollectionOfObjectValues(string.Empty, entityList); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "[{" + + "\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"," + + "\"numbers\":\"one,two\"," + + "\"testNamingEnum\":\"Item2:SubItem1\"," + + "\"mobilePhone\":null," + + "\"accountEnabled\":false," + + "\"jobTitle\":\"Author\"," + + "\"createdDateTime\":\"0001-01-01T00:00:00+00:00\"," + + "\"businessPhones\":[\"\\u002B1 412 555 0109\"]," + + "\"manager\":{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}" + + "}]"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WritesEnumValuesAsCamelCasedIfNotEscaped() + { + // Arrange + var testEntity = new TestEntity() + { + TestNamingEnum = TestNamingEnum.Item1, + }; + var entityList = new List() { testEntity }; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteCollectionOfObjectValues(string.Empty, entityList); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "[{" + + "\"testNamingEnum\":\"item1\"" + // Camel Cased + "}]"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WritesEnumValuesAsDescribedIfEscaped() + { + // Arrange + var testEntity = new TestEntity() + { + TestNamingEnum = TestNamingEnum.Item2SubItem1, + }; + var entityList = new List() { testEntity }; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteCollectionOfObjectValues(string.Empty, entityList); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "[{" + + "\"testNamingEnum\":\"Item2:SubItem1\"" + // Appears same as attribute + "}]"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WriteGuidUsingConverter() + { + // Arrange + var id = Guid.NewGuid(); + var testEntity = new ConverterTestEntity { Id = id }; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General) + { + Converters = { new JsonGuidConverter() } + }; + var serializationContext = new KiotaJsonSerializationContext(serializerOptions); + using var jsonSerializerWriter = new JsonSerializationWriter(serializationContext); + + // Act + jsonSerializerWriter.WriteObjectValue(string.Empty, testEntity); + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = $"{{\"id\":\"{id:N}\"}}"; + Assert.Equal(expectedString, serializedJsonString); + } + + [Fact] + public void WriteGuidUsingNoConverter() + { + // Arrange + var id = Guid.NewGuid(); + var testEntity = new ConverterTestEntity { Id = id }; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.General); + var serializationContext = new KiotaJsonSerializationContext(serializerOptions); + using var jsonSerializerWriter = new JsonSerializationWriter(serializationContext); + + // Act + jsonSerializerWriter.WriteObjectValue(string.Empty, testEntity); + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = $"{{\"id\":\"{id:D}\"}}"; + Assert.Equal(expectedString, serializedJsonString); + } + [Fact] + public void WritesSampleObjectValueWithUntypedProperties() + { + // Arrange + var untypedTestEntity = new UntypedTestEntity + { + Id = "1", + Title = "Title", + Location = new UntypedObject(new Dictionary + { + {"address", new UntypedObject(new Dictionary + { + {"city", new UntypedString("Redmond") }, + {"postalCode", new UntypedString("98052") }, + {"state", new UntypedString("Washington") }, + {"street", new UntypedString("NE 36th St") } + })}, + {"coordinates", new UntypedObject(new Dictionary + { + {"latitude", new UntypedDouble(47.641942d) }, + {"longitude", new UntypedDouble(-122.127222d) } + })}, + {"displayName", new UntypedString("Microsoft Building 92") }, + {"floorCount", new UntypedInteger(50) }, + {"hasReception", new UntypedBoolean(true) }, + {"contact", new UntypedNull() } + }), + Keywords = new UntypedArray(new List + { + new UntypedObject(new Dictionary + { + {"created", new UntypedString("2023-07-26T10:41:26Z") }, + {"label", new UntypedString("Keyword1") }, + {"termGuid", new UntypedString("10e9cc83-b5a4-4c8d-8dab-4ada1252dd70") }, + {"wssId", new UntypedLong(6442450941) } + }), + new UntypedObject(new Dictionary + { + {"created", new UntypedString("2023-07-26T10:51:26Z") }, + {"label", new UntypedString("Keyword2") }, + {"termGuid", new UntypedString("2cae6c6a-9bb8-4a78-afff-81b88e735fef") }, + {"wssId", new UntypedLong(6442450942) } + }) + }), + AdditionalData = new Dictionary + { + { "extra", new UntypedObject(new Dictionary + { + {"createdDateTime", new UntypedString("2024-01-15T00:00:00+00:00") } + }) } + } + }; + using var jsonSerializerWriter = new JsonSerializationWriter(); + // Act + jsonSerializerWriter.WriteObjectValue(string.Empty, untypedTestEntity); + // Get the json string from the stream. + var serializedStream = jsonSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(serializedStream, Encoding.UTF8); + var serializedJsonString = reader.ReadToEnd(); + + // Assert + var expectedString = "{" + + "\"id\":\"1\"," + + "\"title\":\"Title\"," + + "\"location\":{" + + "\"address\":{\"city\":\"Redmond\",\"postalCode\":\"98052\",\"state\":\"Washington\",\"street\":\"NE 36th St\"}," + + "\"coordinates\":{\"latitude\":47.641942,\"longitude\":-122.127222}," + + "\"displayName\":\"Microsoft Building 92\"," + + "\"floorCount\":50," + + "\"hasReception\":true," + + "\"contact\":null}," + + "\"keywords\":[" + + "{\"created\":\"2023-07-26T10:41:26Z\",\"label\":\"Keyword1\",\"termGuid\":\"10e9cc83-b5a4-4c8d-8dab-4ada1252dd70\",\"wssId\":6442450941}," + + "{\"created\":\"2023-07-26T10:51:26Z\",\"label\":\"Keyword2\",\"termGuid\":\"2cae6c6a-9bb8-4a78-afff-81b88e735fef\",\"wssId\":6442450942}]," + + "\"extra\":{\"createdDateTime\":\"2024-01-15T00:00:00\\u002B00:00\"}}"; + Assert.Equal(expectedString, serializedJsonString); + } + } +} diff --git a/tests/serialization/json/Microsoft.Kiota.Serialization.Json.Tests.csproj b/tests/serialization/json/Microsoft.Kiota.Serialization.Json.Tests.csproj new file mode 100644 index 00000000..0735661d --- /dev/null +++ b/tests/serialization/json/Microsoft.Kiota.Serialization.Json.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net462 + true + disable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/serialization/json/Mocks/ConverterTestEntity.cs b/tests/serialization/json/Mocks/ConverterTestEntity.cs new file mode 100644 index 00000000..19ad3a2e --- /dev/null +++ b/tests/serialization/json/Mocks/ConverterTestEntity.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks; + +public class ConverterTestEntity : IParsable +{ + public Guid? Id { get; set; } + + public IDictionary> GetFieldDeserializers() => new Dictionary> + { + { "id", n => Id = n.GetGuidValue() } + }; + + public void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteGuidValue("id", Id); + } +} \ No newline at end of file diff --git a/tests/serialization/json/Mocks/DerivedTestEntity.cs b/tests/serialization/json/Mocks/DerivedTestEntity.cs new file mode 100644 index 00000000..5cad8748 --- /dev/null +++ b/tests/serialization/json/Mocks/DerivedTestEntity.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks +{ + public class DerivedTestEntity : TestEntity + { + /// + /// Date enrolled in primary school + /// + public Date? EnrolmentDate { get; set; } + public override IDictionary> GetFieldDeserializers() + { + var parentDeserializers = base.GetFieldDeserializers(); + parentDeserializers.Add("enrolmentDate", n => { EnrolmentDate = n.GetDateValue(); }); + return parentDeserializers; + } + public override void Serialize(ISerializationWriter writer) + { + base.Serialize(writer); + writer.WriteDateValue("enrolmentDate", EnrolmentDate.Value); + } + } +} \ No newline at end of file diff --git a/tests/serialization/json/Mocks/IntersectionTypeMock.cs b/tests/serialization/json/Mocks/IntersectionTypeMock.cs new file mode 100644 index 00000000..a0e67d8f --- /dev/null +++ b/tests/serialization/json/Mocks/IntersectionTypeMock.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks; + +public class IntersectionTypeMock : IParsable, IComposedTypeWrapper +{ + public TestEntity ComposedType1 { get; set; } + public SecondTestEntity ComposedType2 { get; set; } + public string StringValue { get; set; } + public List ComposedType3 { get; set; } + public static IntersectionTypeMock CreateFromDiscriminator(IParseNode parseNode) { + var result = new IntersectionTypeMock(); + if (parseNode.GetStringValue() is string stringValue) { + result.StringValue = stringValue; + } else if (parseNode.GetCollectionOfObjectValues(TestEntity.CreateFromDiscriminator) is IEnumerable values && values.Any()) { + result.ComposedType3 = values.ToList(); + } else { + result.ComposedType1 = new(); + result.ComposedType2 = new(); + } + return result; + } + public IDictionary> GetFieldDeserializers() { + if(ComposedType1 != null || ComposedType1 != null) { + return ParseNodeHelper.MergeDeserializersForIntersectionWrapper(ComposedType1, ComposedType2); + } + return new Dictionary>(); + } + public void Serialize(ISerializationWriter writer) { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + if (!string.IsNullOrEmpty(StringValue)) { + writer.WriteStringValue(null, StringValue); + } else if (ComposedType3 != null) { + writer.WriteCollectionOfObjectValues(null, ComposedType3); + } else { + writer.WriteObjectValue(null, ComposedType1, ComposedType2); + } + } +} \ No newline at end of file diff --git a/tests/serialization/json/Mocks/SecondTestEntity.cs b/tests/serialization/json/Mocks/SecondTestEntity.cs new file mode 100644 index 00000000..979d69ee --- /dev/null +++ b/tests/serialization/json/Mocks/SecondTestEntity.cs @@ -0,0 +1,25 @@ + +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks; + +public class SecondTestEntity : IParsable +{ + public string DisplayName { get; set; } + public int? Id { get; set; } // intentionally conflicts with the Id property of the TestEntity class + public long? FailureRate { get; set; } + public IDictionary> GetFieldDeserializers() { + return new Dictionary> { + { "displayName", node => DisplayName = node.GetStringValue() }, + { "id", node => Id = node.GetIntValue() }, + { "failureRate", node => FailureRate = node.GetLongValue()}, + }; + } + public void Serialize(ISerializationWriter writer) { + writer.WriteStringValue("displayName", DisplayName); + writer.WriteIntValue("id", Id); + writer.WriteLongValue("failureRate", FailureRate); + } +} \ No newline at end of file diff --git a/tests/serialization/json/Mocks/TestEntity.cs b/tests/serialization/json/Mocks/TestEntity.cs new file mode 100644 index 00000000..46dd21dd --- /dev/null +++ b/tests/serialization/json/Mocks/TestEntity.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks +{ + public class TestEntity : IParsable, IAdditionalDataHolder + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// Read-only. + public string Id { get; set; } + /// Read-only. + public TestEnum? Numbers { get; set; } + /// Read-only. + public TestNamingEnum? TestNamingEnum { get; set; } + /// Read-only. + public TimeSpan? WorkDuration { get; set; } + /// Read-only. + public Date? BirthDay { get; set; } + /// Read-only. + public Time? StartWorkTime { get; set; } + /// Read-only. + public Time? EndWorkTime { get; set; } + /// Read-only. + public DateTimeOffset? CreatedDateTime { get; set; } + /// Read-only. + public decimal? HeightInMetres { get; set; } + /// Read-only. + public string OfficeLocation { get; set; } + /// + /// Instantiates a new entity and sets the default values. + /// + public TestEntity() + { + AdditionalData = new Dictionary(); + } + /// + /// The deserialization information for the current model + /// + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> { + {"id", n => { Id = n.GetStringValue(); } }, + {"numbers", n => { Numbers = n.GetEnumValue(); } }, + {"testNamingEnum", n => { TestNamingEnum = n.GetEnumValue(); } }, + {"createdDateTime", n => { CreatedDateTime = n.GetDateTimeOffsetValue(); } }, + {"officeLocation", n => { OfficeLocation = n.GetStringValue(); } }, + {"workDuration", n => { WorkDuration = n.GetTimeSpanValue(); } }, + {"heightInMetres", n => { HeightInMetres = n.GetDecimalValue(); } }, + {"birthDay", n => { BirthDay = n.GetDateValue(); } }, + {"startWorkTime", n => { StartWorkTime = n.GetTimeValue(); } }, + {"endWorkTime", n => { EndWorkTime = n.GetTimeValue(); } }, + }; + } + /// + /// Serializes information the current object + /// Serialization writer to use to serialize this model + /// + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteEnumValue("numbers",Numbers); + writer.WriteEnumValue("testNamingEnum",TestNamingEnum); + writer.WriteDateTimeOffsetValue("createdDateTime", CreatedDateTime); + writer.WriteStringValue("officeLocation", OfficeLocation); + writer.WriteTimeSpanValue("workDuration", WorkDuration); + writer.WriteDateValue("birthDay", BirthDay); + writer.WriteDecimalValue("heightInMetres", HeightInMetres); + writer.WriteTimeValue("startWorkTime", StartWorkTime); + writer.WriteTimeValue("endWorkTime", EndWorkTime); + writer.WriteAdditionalData(AdditionalData); + } + public static TestEntity CreateFromDiscriminator(IParseNode parseNode) { + var discriminatorValue = parseNode.GetChildNode("@odata.type")?.GetStringValue(); + return discriminatorValue switch + { + "microsoft.graph.user" => new TestEntity(), + "microsoft.graph.group" => new TestEntity(), + "microsoft.graph.student" => new DerivedTestEntity(), + _ => new TestEntity(), + }; + } + } +} diff --git a/tests/serialization/json/Mocks/TestEnum.cs b/tests/serialization/json/Mocks/TestEnum.cs new file mode 100644 index 00000000..3e62ae6c --- /dev/null +++ b/tests/serialization/json/Mocks/TestEnum.cs @@ -0,0 +1,14 @@ +using System; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks +{ + [Flags] + public enum TestEnum + { + One = 0x00000001, + Two = 0x00000002, + Four = 0x00000004, + Eight = 0x00000008, + Sixteen = 0x00000010 + } +} diff --git a/tests/serialization/json/Mocks/TestNamingEnum.cs b/tests/serialization/json/Mocks/TestNamingEnum.cs new file mode 100644 index 00000000..c411c42a --- /dev/null +++ b/tests/serialization/json/Mocks/TestNamingEnum.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks +{ + public enum TestNamingEnum + { + Item1, + [EnumMember(Value = "Item2:SubItem1")] + Item2SubItem1, + [EnumMember(Value = "Item3:SubItem1")] + Item3SubItem1 + } +} diff --git a/tests/serialization/json/Mocks/UnionTypeMock.cs b/tests/serialization/json/Mocks/UnionTypeMock.cs new file mode 100644 index 00000000..99e5996f --- /dev/null +++ b/tests/serialization/json/Mocks/UnionTypeMock.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks; + +public class UnionTypeMock : IParsable, IComposedTypeWrapper +{ + public TestEntity ComposedType1 { get; set; } + public SecondTestEntity ComposedType2 { get; set; } + public string StringValue { get; set; } + public List ComposedType3 { get; set; } + public static UnionTypeMock CreateFromDiscriminator(IParseNode parseNode) { + var result = new UnionTypeMock(); + var discriminator = parseNode.GetChildNode("@odata.type")?.GetStringValue(); + if("#microsoft.graph.testEntity".Equals(discriminator)) { + result.ComposedType1 = new(); + } + else if("#microsoft.graph.secondTestEntity".Equals(discriminator)) { + result.ComposedType2 = new(); + } + else if (parseNode.GetStringValue() is string stringValue) { + result.StringValue = stringValue; + } else if (parseNode.GetCollectionOfObjectValues(TestEntity.CreateFromDiscriminator) is IEnumerable values && values.Any()) { + result.ComposedType3 = values.ToList(); + } + return result; + } + public IDictionary> GetFieldDeserializers() { + if (ComposedType1 != null) + return ComposedType1.GetFieldDeserializers(); + else if (ComposedType2 != null) + return ComposedType2.GetFieldDeserializers(); + //composed type3 is omitted on purpose + return new Dictionary>(); + } + public void Serialize(ISerializationWriter writer) { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + if (ComposedType1 != null) { + writer.WriteObjectValue(null, ComposedType1); + } + else if (ComposedType2 != null) { + writer.WriteObjectValue(null, ComposedType2); + } else if (ComposedType3 != null) { + writer.WriteCollectionOfObjectValues(null, ComposedType3); + } else if (!string.IsNullOrEmpty(StringValue)) { + writer.WriteStringValue(null, StringValue); + } + } +} \ No newline at end of file diff --git a/tests/serialization/json/Mocks/UntypedTestEntity.cs b/tests/serialization/json/Mocks/UntypedTestEntity.cs new file mode 100644 index 00000000..62b94812 --- /dev/null +++ b/tests/serialization/json/Mocks/UntypedTestEntity.cs @@ -0,0 +1,53 @@ + +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Json.Tests.Mocks; + +public class UntypedTestEntity : IParsable, IAdditionalDataHolder +{ + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + public string Id { get; set; } + public string Title { get; set; } + public UntypedNode Location { get; set; } + public UntypedNode Keywords { get; set; } + public UntypedNode Detail { get; set; } + public UntypedNode Table { get; set; } + public UntypedTestEntity() + { + AdditionalData = new Dictionary(); + } + + public IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", node => Id = node.GetStringValue() }, + { "title", node => Title = node.GetStringValue() }, + { "location", node => Location = node.GetObjectValue(UntypedNode.CreateFromDiscriminatorValue) }, + { "keywords", node => Keywords = node.GetObjectValue(UntypedNode.CreateFromDiscriminatorValue) }, + { "detail", node => Detail = node.GetObjectValue(UntypedNode.CreateFromDiscriminatorValue) }, + { "table", node => Table = node.GetObjectValue(UntypedNode.CreateFromDiscriminatorValue) }, + }; + } + public void Serialize(ISerializationWriter writer) + { + writer.WriteStringValue("id", Id); + writer.WriteStringValue("title", Title); + writer.WriteObjectValue("location", Location); + writer.WriteObjectValue("keywords", Keywords); + writer.WriteObjectValue("detail", Detail); + writer.WriteObjectValue("table", Table); + writer.WriteAdditionalData(AdditionalData); + } + public static UntypedTestEntity CreateFromDiscriminatorValue(IParseNode parseNode) + { + var discriminatorValue = parseNode.GetChildNode("@odata.type")?.GetStringValue(); + return discriminatorValue switch + { + _ => new UntypedTestEntity(), + }; + } +} \ No newline at end of file diff --git a/tests/serialization/json/UnionWrapperParseTests.cs b/tests/serialization/json/UnionWrapperParseTests.cs new file mode 100644 index 00000000..e0df3c12 --- /dev/null +++ b/tests/serialization/json/UnionWrapperParseTests.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Kiota.Serialization.Json.Tests.Mocks; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Json.Tests; + +public class UnionWrapperParseTests { + private readonly JsonParseNodeFactory _parseNodeFactory = new(); + private readonly JsonSerializationWriterFactory _serializationWriterFactory = new(); + private const string contentType = "application/json"; + [Fact] + public async Task ParsesUnionTypeComplexProperty1() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("{\"@odata.type\":\"#microsoft.graph.testEntity\",\"officeLocation\":\"Montreal\", \"id\": \"opaque\"}")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(UnionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.NotNull(result.ComposedType1); + Assert.Null(result.ComposedType2); + Assert.Null(result.ComposedType3); + Assert.Null(result.StringValue); + Assert.Equal("opaque", result.ComposedType1.Id); + } + [Fact] + public async Task ParsesUnionTypeComplexProperty2() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("{\"@odata.type\":\"#microsoft.graph.secondTestEntity\",\"officeLocation\":\"Montreal\", \"id\": 10}")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(UnionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.NotNull(result.ComposedType2); + Assert.Null(result.ComposedType1); + Assert.Null(result.ComposedType3); + Assert.Null(result.StringValue); + Assert.Equal(10, result.ComposedType2.Id); + } + [Fact] + public async Task ParsesUnionTypeComplexProperty3() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("[{\"@odata.type\":\"#microsoft.graph.TestEntity\",\"officeLocation\":\"Ottawa\", \"id\": \"11\"}, {\"@odata.type\":\"#microsoft.graph.TestEntity\",\"officeLocation\":\"Montreal\", \"id\": \"10\"}]")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(UnionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.NotNull(result.ComposedType3); + Assert.Null(result.ComposedType2); + Assert.Null(result.ComposedType1); + Assert.Null(result.StringValue); + Assert.Equal(2, result.ComposedType3.Count); + Assert.Equal("11", result.ComposedType3[0].Id); + } + [Fact] + public async Task ParsesUnionTypeStringValue() + { + // Given + using var payload = new MemoryStream(Encoding.UTF8.GetBytes("\"officeLocation\"")); + var parseNode = await _parseNodeFactory.GetRootParseNodeAsync(contentType, payload); + + // When + var result = parseNode.GetObjectValue(UnionTypeMock.CreateFromDiscriminator); + + // Then + Assert.NotNull(result); + Assert.Null(result.ComposedType2); + Assert.Null(result.ComposedType1); + Assert.Equal("officeLocation", result.StringValue); + } + [Fact] + public void SerializesUnionTypeStringValue() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new UnionTypeMock { + StringValue = "officeLocation" + }; + + // When + writer.WriteObjectValue(string.Empty, model); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("\"officeLocation\"", result); + } + [Fact] + public void SerializesUnionTypeComplexProperty1() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new UnionTypeMock { + ComposedType1 = new() { + Id = "opaque", + OfficeLocation = "Montreal", + }, + ComposedType2 = new() { + DisplayName = "McGill", + }, + }; + + // When + writer.WriteObjectValue(string.Empty, model); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("{\"id\":\"opaque\",\"officeLocation\":\"Montreal\"}", result); + } + [Fact] + public void SerializesUnionTypeComplexProperty2() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new UnionTypeMock { + ComposedType2 = new() { + DisplayName = "McGill", + Id = 10, + }, + }; + + // When + writer.WriteObjectValue(string.Empty, model); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("{\"displayName\":\"McGill\",\"id\":10}", result); + } + + [Fact] + public void SerializesUnionTypeComplexProperty3() + { + // Given + using var writer = _serializationWriterFactory.GetSerializationWriter(contentType); + var model = new UnionTypeMock { + ComposedType3 = new() { + new() { + OfficeLocation = "Montreal", + Id = "10", + }, + new() { + OfficeLocation = "Ottawa", + Id = "11", + } + }, + }; + + // When + writer.WriteObjectValue(string.Empty, model); + using var resultStream = writer.GetSerializedContent(); + using var streamReader = new StreamReader(resultStream); + var result = streamReader.ReadToEnd(); + + // Then + Assert.Equal("[{\"id\":\"10\",\"officeLocation\":\"Montreal\"},{\"id\":\"11\",\"officeLocation\":\"Ottawa\"}]", result); + } +} \ No newline at end of file diff --git a/tests/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.Tests.csproj b/tests/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.Tests.csproj new file mode 100644 index 00000000..70a3e0ce --- /dev/null +++ b/tests/serialization/multipart/Microsoft.Kiota.Serialization.Multipart.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net462 + true + disable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + \ No newline at end of file diff --git a/tests/serialization/multipart/Mocks/IntersectionTypeMock.cs b/tests/serialization/multipart/Mocks/IntersectionTypeMock.cs new file mode 100644 index 00000000..4d8054b1 --- /dev/null +++ b/tests/serialization/multipart/Mocks/IntersectionTypeMock.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks; + +public class IntersectionTypeMock : IParsable, IComposedTypeWrapper +{ + public TestEntity ComposedType1 { get; set; } + public SecondTestEntity ComposedType2 { get; set; } + public string StringValue { get; set; } + public List ComposedType3 { get; set; } + public static IntersectionTypeMock CreateFromDiscriminator(IParseNode parseNode) + { + var result = new IntersectionTypeMock(); + if(parseNode.GetStringValue() is string stringValue) + { + result.StringValue = stringValue; + } + else if(parseNode.GetCollectionOfObjectValues(TestEntity.CreateFromDiscriminator) is IEnumerable values && values.Any()) + { + result.ComposedType3 = values.ToList(); + } + else + { + result.ComposedType1 = new(); + result.ComposedType2 = new(); + } + return result; + } + public IDictionary> GetFieldDeserializers() + { + if(ComposedType1 != null || ComposedType1 != null) + { + return ParseNodeHelper.MergeDeserializersForIntersectionWrapper(ComposedType1, ComposedType2); + } + return new Dictionary>(); + } + public void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + if(!string.IsNullOrEmpty(StringValue)) + { + writer.WriteStringValue(null, StringValue); + } + else if(ComposedType3 != null) + { + writer.WriteCollectionOfObjectValues(null, ComposedType3); + } + else + { + writer.WriteObjectValue(null, ComposedType1, ComposedType2); + } + } +} \ No newline at end of file diff --git a/tests/serialization/multipart/Mocks/SecondTestEntity.cs b/tests/serialization/multipart/Mocks/SecondTestEntity.cs new file mode 100644 index 00000000..7237f079 --- /dev/null +++ b/tests/serialization/multipart/Mocks/SecondTestEntity.cs @@ -0,0 +1,27 @@ + +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks; + +public class SecondTestEntity : IParsable +{ + public string DisplayName { get; set; } + public int? Id { get; set; } // intentionally conflicts with the Id property of the TestEntity class + public long? FailureRate { get; set; } + public IDictionary> GetFieldDeserializers() + { + return new Dictionary> { + { "displayName", node => DisplayName = node.GetStringValue() }, + { "id", node => Id = node.GetIntValue() }, + { "failureRate", node => FailureRate = node.GetLongValue()}, + }; + } + public void Serialize(ISerializationWriter writer) + { + writer.WriteStringValue("displayName", DisplayName); + writer.WriteIntValue("id", Id); + writer.WriteLongValue("failureRate", FailureRate); + } +} \ No newline at end of file diff --git a/tests/serialization/multipart/Mocks/TestEntity.cs b/tests/serialization/multipart/Mocks/TestEntity.cs new file mode 100644 index 00000000..20834264 --- /dev/null +++ b/tests/serialization/multipart/Mocks/TestEntity.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks +{ + public class TestEntity : IParsable, IAdditionalDataHolder + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// Read-only. + public string Id { get; set; } + /// Read-only. + public TestEnum? Numbers { get; set; } + /// Read-only. + public TestNamingEnum? TestNamingEnum { get; set; } + /// Read-only. + public TimeSpan? WorkDuration { get; set; } + /// Read-only. + public Date? BirthDay { get; set; } + /// Read-only. + public Time? StartWorkTime { get; set; } + /// Read-only. + public Time? EndWorkTime { get; set; } + /// Read-only. + public DateTimeOffset? CreatedDateTime { get; set; } + /// Read-only. + public string OfficeLocation { get; set; } + /// + /// Instantiates a new entity and sets the default values. + /// + public TestEntity() + { + AdditionalData = new Dictionary(); + } + /// + /// The deserialization information for the current model + /// + public IDictionary> GetFieldDeserializers() + { + return new Dictionary> { + {"id", n => { Id = n.GetStringValue(); } }, + {"numbers", n => { Numbers = n.GetEnumValue(); } }, + {"testNamingEnum", n => { TestNamingEnum = n.GetEnumValue(); } }, + {"createdDateTime", n => { CreatedDateTime = n.GetDateTimeOffsetValue(); } }, + {"officeLocation", n => { OfficeLocation = n.GetStringValue(); } }, + {"workDuration", n => { WorkDuration = n.GetTimeSpanValue(); } }, + {"birthDay", n => { BirthDay = n.GetDateValue(); } }, + {"startWorkTime", n => { StartWorkTime = n.GetTimeValue(); } }, + {"endWorkTime", n => { EndWorkTime = n.GetTimeValue(); } }, + }; + } + /// + /// Serializes information the current object + /// Serialization writer to use to serialize this model + /// + public void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteEnumValue("numbers", Numbers); + writer.WriteEnumValue("testNamingEnum", TestNamingEnum); + writer.WriteDateTimeOffsetValue("createdDateTime", CreatedDateTime); + writer.WriteStringValue("officeLocation", OfficeLocation); + writer.WriteTimeSpanValue("workDuration", WorkDuration); + writer.WriteDateValue("birthDay", BirthDay); + writer.WriteTimeValue("startWorkTime", StartWorkTime); + writer.WriteTimeValue("endWorkTime", EndWorkTime); + writer.WriteAdditionalData(AdditionalData); + } + public static TestEntity CreateFromDiscriminator(IParseNode parseNode) + { + return new TestEntity(); + } + } +} diff --git a/tests/serialization/multipart/Mocks/TestEnum.cs b/tests/serialization/multipart/Mocks/TestEnum.cs new file mode 100644 index 00000000..e601212c --- /dev/null +++ b/tests/serialization/multipart/Mocks/TestEnum.cs @@ -0,0 +1,14 @@ +using System; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks +{ + [Flags] + public enum TestEnum + { + One = 0x00000001, + Two = 0x00000002, + Four = 0x00000004, + Eight = 0x00000008, + Sixteen = 0x00000010 + } +} diff --git a/tests/serialization/multipart/Mocks/TestNamingEnum.cs b/tests/serialization/multipart/Mocks/TestNamingEnum.cs new file mode 100644 index 00000000..c60100e8 --- /dev/null +++ b/tests/serialization/multipart/Mocks/TestNamingEnum.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks +{ + public enum TestNamingEnum + { + Item1, + [EnumMember(Value = "Item2:SubItem1")] + Item2SubItem1, + [EnumMember(Value = "Item3:SubItem1")] + Item3SubItem1 + } +} diff --git a/tests/serialization/multipart/Mocks/UnionTypeMock.cs b/tests/serialization/multipart/Mocks/UnionTypeMock.cs new file mode 100644 index 00000000..722ed0ea --- /dev/null +++ b/tests/serialization/multipart/Mocks/UnionTypeMock.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests.Mocks; + +public class UnionTypeMock : IParsable, IComposedTypeWrapper +{ + public TestEntity ComposedType1 { get; set; } + public SecondTestEntity ComposedType2 { get; set; } + public string StringValue { get; set; } + public List ComposedType3 { get; set; } + public static UnionTypeMock CreateFromDiscriminator(IParseNode parseNode) + { + var result = new UnionTypeMock(); + var discriminator = parseNode.GetChildNode("@odata.type")?.GetStringValue(); + if("#microsoft.graph.testEntity".Equals(discriminator)) + { + result.ComposedType1 = new(); + } + else if("#microsoft.graph.secondTestEntity".Equals(discriminator)) + { + result.ComposedType2 = new(); + } + else if(parseNode.GetStringValue() is string stringValue) + { + result.StringValue = stringValue; + } + else if(parseNode.GetCollectionOfObjectValues(TestEntity.CreateFromDiscriminator) is IEnumerable values && values.Any()) + { + result.ComposedType3 = values.ToList(); + } + return result; + } + public IDictionary> GetFieldDeserializers() + { + if(ComposedType1 != null) + return ComposedType1.GetFieldDeserializers(); + else if(ComposedType2 != null) + return ComposedType2.GetFieldDeserializers(); + //composed type3 is omitted on purpose + return new Dictionary>(); + } + public void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + if(ComposedType1 != null) + { + writer.WriteObjectValue(null, ComposedType1); + } + else if(ComposedType2 != null) + { + writer.WriteObjectValue(null, ComposedType2); + } + else if(ComposedType3 != null) + { + writer.WriteCollectionOfObjectValues(null, ComposedType3); + } + else if(!string.IsNullOrEmpty(StringValue)) + { + writer.WriteStringValue(null, StringValue); + } + } +} \ No newline at end of file diff --git a/tests/serialization/multipart/MultipartSerializationWriterFactoryTests.cs b/tests/serialization/multipart/MultipartSerializationWriterFactoryTests.cs new file mode 100644 index 00000000..40d6d21b --- /dev/null +++ b/tests/serialization/multipart/MultipartSerializationWriterFactoryTests.cs @@ -0,0 +1,46 @@ +using System; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests; +public class MultipartSerializationWriterFactoryTests +{ + private readonly MultipartSerializationWriterFactory _multipartSerializationFactory; + + public MultipartSerializationWriterFactoryTests() + { + _multipartSerializationFactory = new MultipartSerializationWriterFactory(); + } + + [Fact] + public void GetsWriterForMultipartContentType() + { + var multipartWriter = _multipartSerializationFactory.GetSerializationWriter(_multipartSerializationFactory.ValidContentType); + + // Assert + Assert.NotNull(multipartWriter); + Assert.IsAssignableFrom(multipartWriter); + } + + [Fact] + public void ThrowsArgumentOutOfRangeExceptionForInvalidContentType() + { + var streamContentType = "application/octet-stream"; + var exception = Assert.Throws(() => _multipartSerializationFactory.GetSerializationWriter(streamContentType)); + + // Assert + Assert.NotNull(exception); + Assert.Equal($"expected a {_multipartSerializationFactory.ValidContentType} content type", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ThrowsArgumentNullExceptionForNoContentType(string contentType) + { + var exception = Assert.Throws(() => _multipartSerializationFactory.GetSerializationWriter(contentType)); + + // Assert + Assert.NotNull(exception); + Assert.Equal("contentType", exception.ParamName); + } +} diff --git a/tests/serialization/multipart/MultipartSerializationWriterTests.cs b/tests/serialization/multipart/MultipartSerializationWriterTests.cs new file mode 100644 index 00000000..78ce0dcc --- /dev/null +++ b/tests/serialization/multipart/MultipartSerializationWriterTests.cs @@ -0,0 +1,135 @@ +using Xunit; +using System; +using System.IO; +using System.Collections.Generic; +using Microsoft.Kiota.Serialization.Multipart.Tests.Mocks; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Serialization.Json; +using Moq; + +namespace Microsoft.Kiota.Serialization.Multipart.Tests; +public class MultipartSerializationWriterTests +{ + [Fact] + public void ThrowsOnParsable() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + AdditionalData = new Dictionary + { + {"mobilePhone",null}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"businessPhones", new List() {"+1 412 555 0109"}}, // write collection of primitives value + {"endDateTime", new DateTime(2023,03,14,0,0,0,DateTimeKind.Utc) }, // ensure the DateTime doesn't crash + {"manager", new TestEntity{Id = "48d31887-5fad-4d73-a9f5-3c356e68a038"}}, // write nested object value + } + }; + using var mpSerializerWriter = new MultipartSerializationWriter(); + // Act + Assert.Throws(() => mpSerializerWriter.WriteObjectValue(string.Empty, testEntity)); + } + [Fact] + public void WritesStringValue() + { + using var mpSerializerWriter = new MultipartSerializationWriter(); + mpSerializerWriter.WriteStringValue("key", "value"); + using var stream = mpSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + Assert.Equal("key: value\r\n", content); + } + [Fact] + public void WriteByteArrayValue() + { + using var mpSerializerWriter = new MultipartSerializationWriter(); + mpSerializerWriter.WriteByteArrayValue("key", new byte[] { 1, 2, 3 }); + using var stream = mpSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + Assert.Equal("\u0001\u0002\u0003", content); + } + [Fact] + public void WritesAStructuredObject() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + AdditionalData = new Dictionary + { + {"mobilePhone",null}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"businessPhones", new List() {"+1 412 555 0109"}}, // write collection of primitives value + {"endDateTime", new DateTime(2023,03,14,0,0,0,DateTimeKind.Utc) }, // ensure the DateTime doesn't crash + {"manager", new TestEntity{Id = "48d31887-5fad-4d73-a9f5-3c356e68a038"}}, // write nested object value + } + }; + var mockRequestAdapter = new Mock(); + mockRequestAdapter.SetupGet(x => x.SerializationWriterFactory).Returns(new JsonSerializationWriterFactory()); + var mpBody = new MultipartBody + { + RequestAdapter = mockRequestAdapter.Object + }; + mpBody.AddOrReplacePart("testEntity", "application/json", testEntity); + mpBody.AddOrReplacePart("image", "application/octet-stream", new byte[] { 1, 2, 3 }); + + using var mpSerializerWriter = new MultipartSerializationWriter(); + // Act + mpSerializerWriter.WriteObjectValue(string.Empty, mpBody); + using var stream = mpSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + Assert.Equal("--" + mpBody.Boundary + "\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name=\"testEntity\"\r\n\r\n{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\",\"workDuration\":\"PT1H\",\"birthDay\":\"2017-09-04\",\"startWorkTime\":\"08:00:00\",\"mobilePhone\":null,\"accountEnabled\":false,\"jobTitle\":\"Author\",\"createdDateTime\":\"0001-01-01T00:00:00+00:00\",\"businessPhones\":[\"\\u002B1 412 555 0109\"],\"endDateTime\":\"2023-03-14T00:00:00+00:00\",\"manager\":{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}}\r\n--" + mpBody.Boundary + "\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name=\"image\"\r\n\r\n\u0001\u0002\u0003\r\n--" + mpBody.Boundary + "--\r\n", content); + } + [Fact] + public void WritesAStructuredObjectInverted() + { + // Arrange + var testEntity = new TestEntity() + { + Id = "48d31887-5fad-4d73-a9f5-3c356e68a038", + WorkDuration = TimeSpan.FromHours(1), + StartWorkTime = new Time(8, 0, 0), + BirthDay = new Date(2017, 9, 4), + AdditionalData = new Dictionary + { + {"mobilePhone",null}, // write null value + {"accountEnabled",false}, // write bool value + {"jobTitle","Author"}, // write string value + {"createdDateTime", DateTimeOffset.MinValue}, // write date value + {"businessPhones", new List() {"+1 412 555 0109"}}, // write collection of primitives value + {"endDateTime", new DateTime(2023,03,14,0,0,0,DateTimeKind.Utc) }, // ensure the DateTime doesn't crash + {"manager", new TestEntity{Id = "48d31887-5fad-4d73-a9f5-3c356e68a038"}}, // write nested object value + } + }; + var mockRequestAdapter = new Mock(); + mockRequestAdapter.SetupGet(x => x.SerializationWriterFactory).Returns(new JsonSerializationWriterFactory()); + var mpBody = new MultipartBody + { + RequestAdapter = mockRequestAdapter.Object + }; + mpBody.AddOrReplacePart("image", "application/octet-stream", new byte[] { 1, 2, 3 }); + mpBody.AddOrReplacePart("testEntity", "application/json", testEntity); + + using var mpSerializerWriter = new MultipartSerializationWriter(); + // Act + mpSerializerWriter.WriteObjectValue(string.Empty, mpBody); + using var stream = mpSerializerWriter.GetSerializedContent(); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + Assert.Equal("--" + mpBody.Boundary + "\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name=\"image\"\r\n\r\n\u0001\u0002\u0003\r\n--" + mpBody.Boundary + "\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name=\"testEntity\"\r\n\r\n{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\",\"workDuration\":\"PT1H\",\"birthDay\":\"2017-09-04\",\"startWorkTime\":\"08:00:00\",\"mobilePhone\":null,\"accountEnabled\":false,\"jobTitle\":\"Author\",\"createdDateTime\":\"0001-01-01T00:00:00+00:00\",\"businessPhones\":[\"\\u002B1 412 555 0109\"],\"endDateTime\":\"2023-03-14T00:00:00+00:00\",\"manager\":{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\"}}\r\n--" + mpBody.Boundary + "--\r\n", content); + } +} \ No newline at end of file diff --git a/tests/serialization/text/Microsoft.Kiota.Serialization.Text.Tests.csproj b/tests/serialization/text/Microsoft.Kiota.Serialization.Text.Tests.csproj new file mode 100644 index 00000000..c214e2b4 --- /dev/null +++ b/tests/serialization/text/Microsoft.Kiota.Serialization.Text.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net462 + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/serialization/text/TextSerializationWriterTests.cs b/tests/serialization/text/TextSerializationWriterTests.cs new file mode 100644 index 00000000..02846b24 --- /dev/null +++ b/tests/serialization/text/TextSerializationWriterTests.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.Kiota.Serialization.Text.Tests; + +public class TextSerializationWriterTests +{ + [Fact] + public void WritesStringValue() + { + // Arrange + var value = "This is a string value"; + + using var textSerializationWriter = new TextSerializationWriter(); + + // Act + textSerializationWriter.WriteStringValue(null, value); + var contentStream = textSerializationWriter.GetSerializedContent(); + using var reader = new StreamReader(contentStream, Encoding.UTF8); + var serializedString = reader.ReadToEnd(); + + // Assert + Assert.Equal(value, serializedString); + } + + [Fact] + public void StreamIsReadableAfterDispose() + { + // Arrange + var value = "This is a string value"; + + using var textSerializationWriter = new TextSerializationWriter(); + + // Act + textSerializationWriter.WriteStringValue(null, value); + var contentStream = textSerializationWriter.GetSerializedContent(); + // Dispose the writer + textSerializationWriter.Dispose(); + using var reader = new StreamReader(contentStream, Encoding.UTF8); + var serializedString = reader.ReadToEnd(); + + // Assert + Assert.Equal(value, serializedString); + } +} diff --git a/tests/serialization/text/UnitTest1.cs b/tests/serialization/text/UnitTest1.cs new file mode 100644 index 00000000..af7f54cc --- /dev/null +++ b/tests/serialization/text/UnitTest1.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace Microsoft.Kiota.Serialization.Text.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file