From b4b43e39bc3b79b773d81859efd0f4ad558c24e7 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sun, 27 Mar 2022 20:29:23 +0800 Subject: [PATCH] Add gRPC JSON transcoding (#40242) --- AspNetCore.sln | 205 +++- Directory.Build.props | 1 + eng/Build.props | 19 +- eng/CodeGen.proj | 39 +- eng/Dependencies.props | 3 + eng/ProjectReferences.props | 2 + eng/RequiresDelayedBuildProjects.props | 20 + eng/SharedFramework.Local.props | 2 +- eng/Versions.props | 12 +- eng/targets/ResolveReferences.targets | 8 +- .../BuildAfterTargetingPack.csproj | 94 ++ src/Grpc/Grpc.slnf | 33 +- .../InteropTests/Helpers/ClientProcess.cs | 3 - .../InteropTests/Helpers/WebsiteProcess.cs | 3 - .../test/InteropTests/InteropTests.cs | 4 - .../test/InteropTests/InteropTests.csproj | 6 +- .../test/testassets/.editorconfig | 0 .../test/testassets/InteropClient/Assert.cs | 2 - .../InteropClient/AsyncStreamExtensions.cs | 3 - .../InteropClient/IChannelWrapper.cs | 2 - .../testassets/InteropClient/InteropClient.cs | 6 - .../InteropClient/InteropClient.csproj | 0 .../test/testassets/InteropClient/Program.cs | 1 - .../testassets/InteropClient/RunTests.ps1 | 0 .../InteropWebsite/AsyncStreamExtensions.cs | 3 - .../InteropWebsite/InteropWebsite.csproj | 0 .../test/testassets/InteropWebsite/Program.cs | 6 - .../Properties/launchSettings.json | 6 +- .../test/testassets/InteropWebsite/Startup.cs | 4 - .../InteropWebsite/TestServiceImpl.cs | 6 +- .../testassets/Proto/grpc/testing/empty.proto | 0 .../Proto/grpc/testing/messages.proto | 0 .../testassets/Proto/grpc/testing/test.proto | 0 .../{ => Interop}/test/testassets/README.md | 0 .../JsonTranscoding/Directory.Build.props | 15 + .../Json/JsonReading.cs | 39 + .../Json/JsonWriting.cs | 40 + ...oft.AspNetCore.Grpc.Microbenchmarks.csproj | 22 + .../Properties/AssemblyInfo.cs | 4 + .../Proto/chat.proto | 18 + .../Proto/greet.proto | 26 + .../GrpcJsonSettings.cs | 36 + .../GrpcJsonTranscodingMetadata.cs | 35 + .../GrpcJsonTranscodingOptions.cs | 40 + .../GrpcJsonTranscodingServiceExtensions.cs | 50 + .../Binding/HttpApiProviderSeviceBinder.cs | 294 ++++++ .../Binding/HttpApiServiceMethodProvider.cs | 94 ++ .../Binding/IServiceInvokerResolver.cs | 20 + .../ReflectionServiceInvokerResolver.cs | 85 ++ .../CallHandlers/CallHandlerDescriptorInfo.cs | 40 + .../CallHandlers/ServerCallHandlerBase.cs | 78 ++ .../ServerStreamingServerCallHandler.cs | 44 + .../CallHandlers/UnaryServerCallHandler.cs | 49 + .../Internal/CommonGrpcProtocolHelpers.cs | 41 + .../Internal/ErrorMessageHelper.cs | 17 + .../Internal/GrpcProtocolConstants.cs | 25 + .../Internal/GrpcProtocolHelpers.cs | 32 + .../Internal/GrpcServerLog.cs | 43 + .../Internal/HttpApiServerCallContext.cs | 238 +++++ .../Internal/HttpContextStreamWriter.cs | 81 ++ .../Internal/Json/AnyConverter.cs | 94 ++ .../Internal/Json/BoolConverter.cs | 26 + .../Internal/Json/ByteStringConverter.cs | 23 + .../Internal/Json/DurationConverter.cs | 58 ++ .../Internal/Json/EnumConverter.cs | 125 +++ .../Internal/Json/FieldMaskConverter.cs | 53 + .../Internal/Json/Int64Converter.cs | 37 + .../Internal/Json/JsonContext.cs | 18 + .../Json/JsonConverterFactoryForEnum.cs | 37 + .../Json/JsonConverterFactoryForMessage.cs | 38 + .../JsonConverterFactoryForWellKnownTypes.cs | 64 ++ .../Json/JsonConverterFactoryForWrappers.cs | 50 + .../Internal/Json/JsonConverterHelper.cs | 136 +++ .../Internal/Json/Legacy.cs | 403 ++++++++ .../Internal/Json/ListValueConverter.cs | 32 + .../Internal/Json/MessageConverter.cs | 187 ++++ .../Internal/Json/NullValueConverter.cs | 41 + .../Internal/Json/SettingsConverterBase.cs | 16 + .../Internal/Json/StructConverter.cs | 46 + .../Internal/Json/TimestampConverter.cs | 57 ++ .../Internal/Json/UInt64Converter.cs | 38 + .../Internal/Json/ValueConverter.cs | 87 ++ .../Internal/Json/WrapperConverter.cs | 33 + .../Internal/JsonRequestHelpers.cs | 346 +++++++ .../Internal/Protos/errors.proto | 26 + ...oft.AspNetCore.Grpc.JsonTranscoding.csproj | 33 + .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 24 + .../GrpcSwaggerGenOptionsExtensions.cs | 78 ++ .../GrpcSwaggerServiceExtensions.cs | 64 ++ .../Internal/GrpcDataContractResolver.cs | 143 +++ .../GrpcHttpApiDescriptionProvider.cs | 138 +++ .../Internal/GrpcModelMetadata.cs | 54 + .../Internal/MessageDescriptorHelpers.cs | 52 + .../GrpcXmlCommentsDocumentFilter.cs | 79 ++ .../GrpcXmlCommentsOperationFilter.cs | 110 ++ .../Microsoft.AspNetCore.Grpc.Swagger.csproj | 18 + .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 8 + .../JsonTranscoding/src/Shared/.editorconfig | 3 + .../src/Shared/AuthContextHelpers.cs | 72 ++ .../src/Shared/Server/BindMethodFinder.cs | 102 ++ .../ClientStreamingServerMethodInvoker.cs | 119 +++ .../DuplexStreamingServerMethodInvoker.cs | 122 +++ .../Server/InterceptorPipelineBuilder.cs | 186 ++++ .../src/Shared/Server/MethodOptions.cs | 161 +++ .../Shared/Server/ServerMethodInvokerBase.cs | 65 ++ .../ServerStreamingServerMethodInvoker.cs | 122 +++ .../Shared/Server/UnaryServerMethodInvoker.cs | 116 +++ .../src/Shared/ServiceDescriptorHelpers.cs | 407 ++++++++ .../src/Shared/X509CertificateHelpers.cs | 179 ++++ .../DynamicGrpcServiceRegistry.cs | 166 +++ .../ForwardingLoggerProvider.cs | 52 + .../Infrastructure/GrpcTestContext.cs | 35 + .../Infrastructure/GrpcTestFixture.cs | 94 ++ .../Infrastructure/SyncPoint.cs | 89 ++ .../Infrastructure/TaskExtensions.cs | 158 +++ .../Infrastructure/TestHelpers.cs | 72 ++ .../IntegrationTestBase.cs | 40 + ...pc.JsonTranscoding.IntegrationTests.csproj | 16 + .../ServerStreamingTests.cs | 80 ++ .../UnaryTests.cs | 224 ++++ .../ConverterTests/JsonConverterReadTests.cs | 479 +++++++++ .../ConverterTests/JsonConverterWriteTests.cs | 495 +++++++++ .../ConverterTests/JsonElementComparer.cs | 158 +++ .../GrpcHttpApiServiceExtensionsTests.cs | 51 + .../HttpApiServerCallContextTests.cs | 105 ++ .../HttpApiServiceMethodProviderTests.cs | 235 +++++ .../Infrastructure/HttpApiGreeterService.cs | 15 + .../Infrastructure/SyncPoint.cs | 89 ++ .../Infrastructure/TestHelpers.cs | 73 ++ ...pNetCore.Grpc.JsonTranscoding.Tests.csproj | 14 + .../Proto/transcoding.proto | 194 ++++ .../ServerStreamingServerCallHandlerTests.cs | 229 +++++ .../Services/HttpApiGreeterService.cs | 10 + .../HttpApiInvalidBodyGreeterService.cs | 10 + .../HttpApiInvalidPatternGreeterService.cs | 10 + ...ttpApiInvalidResponseBodyGreeterService.cs | 10 + .../Services/HttpApiStreamingService.cs | 10 + .../UnaryServerCallHandlerTests.cs | 966 ++++++++++++++++++ .../google/api/annotations.proto | 31 + .../google/api/http.proto | 376 +++++++ .../GrpcSwaggerServiceExtensionsTests.cs | 107 ++ ...osoft.AspNetCore.Grpc.Swagger.Tests.csproj | 17 + .../Proto/counter.proto | 25 + .../Proto/greeter.proto | 30 + .../Proto/messages.proto | 104 ++ .../Proto/xmldoc.proto | 64 ++ .../SchemaGeneratorIntegrationTests.cs | 169 +++ .../Services/GreeterService.cs | 24 + .../Services/XmlDocService.cs | 33 + .../Services/XmlDocServiceWithComments.cs | 48 + .../XmlCommentsDocumentFilterTests.cs | 72 ++ .../XmlDocumentationIntegrationTests.cs | 175 ++++ .../google/api/annotations.proto | 31 + .../google/api/http.proto | 376 +++++++ .../test/Shared/TestGrpcServiceActivator.cs | 19 + .../DynamicEndpointDataSource.cs | 63 ++ .../Infrastructure/DynamicService.cs | 8 + .../DynamicServiceModelProvider.cs | 19 + .../IntegrationTestsWebsite.csproj | 13 + .../IntegrationTestsWebsite/Program.cs | 19 + .../Properties/launchSettings.json | 12 + .../Protos/greet.proto | 33 + .../Services/GreeterService.cs | 23 + .../IntegrationTestsWebsite/Startup.cs | 38 + .../appsettings.Development.json | 8 + .../IntegrationTestsWebsite/appsettings.json | 14 + .../google/api/annotations.proto | 31 + .../google/api/http.proto | 376 +++++++ .../Sandbox/Controllers/ValuesController.cs | 43 + .../test/testassets/Sandbox/Program.cs | 19 + .../Sandbox/Properties/launchSettings.json | 29 + .../test/testassets/Sandbox/Sandbox.csproj | 14 + .../Sandbox/Services/GreeterService.cs | 40 + .../test/testassets/Sandbox/Startup.cs | 47 + .../Sandbox/appsettings.Development.json | 10 + .../test/testassets/Sandbox/appsettings.json | 8 + .../Sandbox/google/api/annotations.proto | 31 + .../testassets/Sandbox/google/api/http.proto | 376 +++++++ .../test/testassets/Sandbox/greet.proto | 36 + .../test/testassets/Sandbox/transcoding.proto | 169 +++ src/Grpc/THIRD-PARTY-NOTICES | 21 +- .../EndpointMetadataApiDescriptionProvider.cs | 2 +- 184 files changed, 13831 insertions(+), 78 deletions(-) create mode 100644 eng/RequiresDelayedBuildProjects.props create mode 100644 src/BuildAfterTargetingPack/BuildAfterTargetingPack.csproj rename src/Grpc/{ => Interop}/test/InteropTests/Helpers/ClientProcess.cs (97%) rename src/Grpc/{ => Interop}/test/InteropTests/Helpers/WebsiteProcess.cs (97%) rename src/Grpc/{ => Interop}/test/InteropTests/InteropTests.cs (98%) rename src/Grpc/{ => Interop}/test/InteropTests/InteropTests.csproj (70%) rename src/Grpc/{ => Interop}/test/testassets/.editorconfig (100%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/Assert.cs (98%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/AsyncStreamExtensions.cs (97%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/IChannelWrapper.cs (95%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/InteropClient.cs (99%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/InteropClient.csproj (100%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/Program.cs (98%) rename src/Grpc/{ => Interop}/test/testassets/InteropClient/RunTests.ps1 (100%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/AsyncStreamExtensions.cs (94%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/InteropWebsite.csproj (100%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/Program.cs (93%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/Properties/launchSettings.json (88%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/Startup.cs (94%) rename src/Grpc/{ => Interop}/test/testassets/InteropWebsite/TestServiceImpl.cs (98%) rename src/Grpc/{ => Interop}/test/testassets/Proto/grpc/testing/empty.proto (100%) rename src/Grpc/{ => Interop}/test/testassets/Proto/grpc/testing/messages.proto (100%) rename src/Grpc/{ => Interop}/test/testassets/Proto/grpc/testing/test.proto (100%) rename src/Grpc/{ => Interop}/test/testassets/README.md (100%) create mode 100644 src/Grpc/JsonTranscoding/Directory.Build.props create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonReading.cs create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonWriting.cs create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Properties/AssemblyInfo.cs create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/chat.proto create mode 100644 src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/greet.proto create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingMetadata.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingOptions.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingServiceExtensions.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiProviderSeviceBinder.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiServiceMethodProvider.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/IServiceInvokerResolver.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/ReflectionServiceInvokerResolver.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerStreamingServerCallHandler.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CommonGrpcProtocolHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/ErrorMessageHelper.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolConstants.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcServerLog.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpApiServerCallContext.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/AnyConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/BoolConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ByteStringConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/DurationConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/FieldMaskConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Int64Converter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonContext.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForEnum.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForMessage.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWellKnownTypes.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWrappers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterHelper.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Legacy.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ListValueConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/NullValueConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/SettingsConverterBase.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/StructConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/TimestampConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/UInt64Converter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ValueConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/WrapperConverter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Protos/errors.proto create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Shipped.txt create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Unshipped.txt create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerGenOptionsExtensions.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerServiceExtensions.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcDataContractResolver.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcHttpApiDescriptionProvider.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcModelMetadata.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/MessageDescriptorHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsDocumentFilter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsOperationFilter.cs create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Shipped.txt create mode 100644 src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Unshipped.txt create mode 100644 src/Grpc/JsonTranscoding/src/Shared/.editorconfig create mode 100644 src/Grpc/JsonTranscoding/src/Shared/AuthContextHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/BindMethodFinder.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/ClientStreamingServerMethodInvoker.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/DuplexStreamingServerMethodInvoker.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/InterceptorPipelineBuilder.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/MethodOptions.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/ServerMethodInvokerBase.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/ServerStreamingServerMethodInvoker.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/Server/UnaryServerMethodInvoker.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/src/Shared/X509CertificateHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/DynamicGrpcServiceRegistry.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/ForwardingLoggerProvider.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestContext.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestFixture.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/SyncPoint.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TaskExtensions.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/IntegrationTestBase.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/ServerStreamingTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/UnaryTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterReadTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterWriteTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonElementComparer.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/GrpcHttpApiServiceExtensionsTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServerCallContextTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServiceMethodProviderTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/HttpApiGreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/SyncPoint.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/transcoding.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiGreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidBodyGreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidPatternGreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidResponseBodyGreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiStreamingService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/annotations.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/http.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/counter.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/greeter.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/messages.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/xmldoc.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/SchemaGeneratorIntegrationTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/GreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocService.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocServiceWithComments.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlCommentsDocumentFilterTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/annotations.proto create mode 100644 src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/http.proto create mode 100644 src/Grpc/JsonTranscoding/test/Shared/TestGrpcServiceActivator.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicEndpointDataSource.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicService.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicServiceModelProvider.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/IntegrationTestsWebsite.csproj create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Program.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Properties/launchSettings.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Services/GreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Startup.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.Development.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/annotations.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/http.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Controllers/ValuesController.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Program.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Properties/launchSettings.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Sandbox.csproj create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Services/GreeterService.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/Startup.cs create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.Development.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.json create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/annotations.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/http.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/greet.proto create mode 100644 src/Grpc/JsonTranscoding/test/testassets/Sandbox/transcoding.proto diff --git a/AspNetCore.sln b/AspNetCore.sln index 41bcbccec2e..2594c3c89d2 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -559,7 +559,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{8DAC59BE-C EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{E763DA15-8F4E-446C-99B8-309053C75598}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropTests", "src\Grpc\test\InteropTests\InteropTests.csproj", "{3ADC50B9-2EBB-422A-8424-F9FC67841CA1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropTests", "src\Grpc\Interop\test\InteropTests\InteropTests.csproj", "{3ADC50B9-2EBB-422A-8424-F9FC67841CA1}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{05A169C7-4F20-4516-B10A-B13C5649D346}" EndProject @@ -1318,9 +1318,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Object EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "testassets", "testassets", "{00B2DD87-7E2A-4460-BE1B-5E18B1062B7F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropClient", "src\Grpc\test\testassets\InteropClient\InteropClient.csproj", "{C3A0F425-669F-46A8-893F-CF449A6DAE56}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropClient", "src\Grpc\Interop\test\testassets\InteropClient\InteropClient.csproj", "{C3A0F425-669F-46A8-893F-CF449A6DAE56}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropWebsite", "src\Grpc\test\testassets\InteropWebsite\InteropWebsite.csproj", "{19189670-E206-471D-94F8-7D3D545E5020}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InteropWebsite", "src\Grpc\Interop\test\testassets\InteropWebsite\InteropWebsite.csproj", "{19189670-E206-471D-94F8-7D3D545E5020}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wasm.Performance.ConsoleHost", "src\Components\benchmarkapps\Wasm.Performance\ConsoleHost\Wasm.Performance.ConsoleHost.csproj", "{E9408723-E6A9-4715-B906-3B25B0238ABA}" EndProject @@ -1654,16 +1654,48 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SDK-Analyzers", "SDK-Analyz EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Components", "Components", "{CC45FA2D-128B-485D-BA6D-DFD9735CB3C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.SdkAnalyzers", "src\Tools\SDK-Analyzers\Components\src\Microsoft.AspNetCore.Components.SdkAnalyzers.csproj", "{825BCF97-67A9-4834-B3A8-C3DC97A90E41}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.SdkAnalyzers", "src\Tools\SDK-Analyzers\Components\src\Microsoft.AspNetCore.Components.SdkAnalyzers.csproj", "{825BCF97-67A9-4834-B3A8-C3DC97A90E41}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.SdkAnalyzers.Tests", "src\Tools\SDK-Analyzers\Components\test\Microsoft.AspNetCore.Components.SdkAnalyzers.Tests.csproj", "{DC349A25-0DBF-4468-99E1-B95C22D3A7EF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.SdkAnalyzers.Tests", "src\Tools\SDK-Analyzers\Components\test\Microsoft.AspNetCore.Components.SdkAnalyzers.Tests.csproj", "{DC349A25-0DBF-4468-99E1-B95C22D3A7EF}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LinkabilityChecker", "LinkabilityChecker", "{94F95276-7CDF-44A8-B159-D09702EF6794}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkabilityChecker", "src\Tools\LinkabilityChecker\LinkabilityChecker.csproj", "{EA7D844B-C180-41C7-9D55-273AD88BF71F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Interop", "Interop", "{7A331A1C-E2C4-4E37-B0A0-B5AA10661229}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JsonTranscoding", "JsonTranscoding", "{DD076DDA-7956-4361-A7D4-2B8025AB3DFD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{5CDB8ABC-9DD0-4A9F-8948-EED5FFE89F67}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{151E6F9E-107B-4DDC-A2B1-95115801FD14}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{B43BE3EB-9846-4484-88D8-05165202A0FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "testassets", "testassets", "{9A8AE587-A3DB-4211-8354-430C4CCBEB9B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestsWebsite", "src\Grpc\JsonTranscoding\test\testassets\IntegrationTestsWebsite\IntegrationTestsWebsite.csproj", "{2E28881D-A188-47AF-800A-B5877AD8C288}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sandbox", "src\Grpc\JsonTranscoding\test\testassets\Sandbox\Sandbox.csproj", "{A53696E8-6065-41BA-84FB-E89E0DACFF6C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.JsonTranscoding", "src\Grpc\JsonTranscoding\src\Microsoft.AspNetCore.Grpc.JsonTranscoding\Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj", "{109C702D-DACE-4F82-A490-15E5AFA94005}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.Swagger", "src\Grpc\JsonTranscoding\src\Microsoft.AspNetCore.Grpc.Swagger\Microsoft.AspNetCore.Grpc.Swagger.csproj", "{E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.Swagger.Tests", "src\Grpc\JsonTranscoding\test\Microsoft.AspNetCore.Grpc.Swagger.Tests\Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj", "{90CF4DC6-AC53-459F-9EAB-623A11EADAA3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests", "src\Grpc\JsonTranscoding\test\Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests\Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj", "{F18E97AE-3A3F-424D-8DC2-4D001A167F98}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests", "src\Grpc\JsonTranscoding\test\Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests\Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj", "{8C3E422A-F281-4B93-A567-88C7A1ED0412}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Grpc.Microbenchmarks", "src\Grpc\JsonTranscoding\perf\Microsoft.AspNetCore.Grpc.Microbenchmarks\Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj", "{EB14F068-AD55-4970-B9B4-1FBE33704243}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IIS.LongTests", "src\Servers\IIS\IIS\test\IIS.LongTests\IIS.LongTests.csproj", "{B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildAfterTargetingPack", "BuildAfterTargetingPack", "{489020F2-80D9-4468-A5D3-07E785837A5D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildAfterTargetingPack", "src\BuildAfterTargetingPack\BuildAfterTargetingPack.csproj", "{8FED7E65-A7DD-4F13-8980-BF03E77B6C85}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -9959,6 +9991,134 @@ Global {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Release|x64.Build.0 = Release|Any CPU {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Release|x86.ActiveCfg = Release|Any CPU {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Release|x86.Build.0 = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|arm64.ActiveCfg = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|arm64.Build.0 = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|x64.Build.0 = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Debug|x86.Build.0 = Debug|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|Any CPU.Build.0 = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|arm64.ActiveCfg = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|arm64.Build.0 = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|x64.ActiveCfg = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|x64.Build.0 = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|x86.ActiveCfg = Release|Any CPU + {2E28881D-A188-47AF-800A-B5877AD8C288}.Release|x86.Build.0 = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|arm64.ActiveCfg = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|arm64.Build.0 = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|x64.Build.0 = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Debug|x86.Build.0 = Debug|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|Any CPU.Build.0 = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|arm64.ActiveCfg = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|arm64.Build.0 = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|x64.ActiveCfg = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|x64.Build.0 = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|x86.ActiveCfg = Release|Any CPU + {A53696E8-6065-41BA-84FB-E89E0DACFF6C}.Release|x86.Build.0 = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|arm64.ActiveCfg = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|arm64.Build.0 = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|x64.ActiveCfg = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|x64.Build.0 = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|x86.ActiveCfg = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Debug|x86.Build.0 = Debug|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|Any CPU.Build.0 = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|arm64.ActiveCfg = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|arm64.Build.0 = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|x64.ActiveCfg = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|x64.Build.0 = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|x86.ActiveCfg = Release|Any CPU + {109C702D-DACE-4F82-A490-15E5AFA94005}.Release|x86.Build.0 = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|arm64.ActiveCfg = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|arm64.Build.0 = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|x64.Build.0 = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Debug|x86.Build.0 = Debug|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|Any CPU.Build.0 = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|arm64.ActiveCfg = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|arm64.Build.0 = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|x64.ActiveCfg = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|x64.Build.0 = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|x86.ActiveCfg = Release|Any CPU + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11}.Release|x86.Build.0 = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|arm64.ActiveCfg = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|arm64.Build.0 = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|x64.ActiveCfg = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|x64.Build.0 = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|x86.ActiveCfg = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Debug|x86.Build.0 = Debug|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|Any CPU.Build.0 = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|arm64.ActiveCfg = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|arm64.Build.0 = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|x64.ActiveCfg = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|x64.Build.0 = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|x86.ActiveCfg = Release|Any CPU + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3}.Release|x86.Build.0 = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|arm64.ActiveCfg = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|arm64.Build.0 = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|x64.ActiveCfg = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|x64.Build.0 = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|x86.ActiveCfg = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Debug|x86.Build.0 = Debug|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|Any CPU.Build.0 = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|arm64.ActiveCfg = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|arm64.Build.0 = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|x64.ActiveCfg = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|x64.Build.0 = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|x86.ActiveCfg = Release|Any CPU + {F18E97AE-3A3F-424D-8DC2-4D001A167F98}.Release|x86.Build.0 = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|arm64.Build.0 = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|x64.Build.0 = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Debug|x86.Build.0 = Debug|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|Any CPU.Build.0 = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|arm64.ActiveCfg = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|arm64.Build.0 = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|x64.ActiveCfg = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|x64.Build.0 = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|x86.ActiveCfg = Release|Any CPU + {8C3E422A-F281-4B93-A567-88C7A1ED0412}.Release|x86.Build.0 = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|arm64.ActiveCfg = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|arm64.Build.0 = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|x64.Build.0 = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Debug|x86.Build.0 = Debug|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|Any CPU.Build.0 = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|arm64.ActiveCfg = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|arm64.Build.0 = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|x64.ActiveCfg = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|x64.Build.0 = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|x86.ActiveCfg = Release|Any CPU + {EB14F068-AD55-4970-B9B4-1FBE33704243}.Release|x86.Build.0 = Release|Any CPU {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -9975,6 +10135,22 @@ Global {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Release|x64.Build.0 = Release|Any CPU {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Release|x86.ActiveCfg = Release|Any CPU {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04}.Release|x86.Build.0 = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|arm64.Build.0 = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|x64.ActiveCfg = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|x64.Build.0 = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|x86.ActiveCfg = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Debug|x86.Build.0 = Debug|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|Any CPU.Build.0 = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|arm64.ActiveCfg = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|arm64.Build.0 = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x64.ActiveCfg = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x64.Build.0 = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.ActiveCfg = Release|Any CPU + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -10247,7 +10423,7 @@ Global {58955E85-0D55-45FF-97EE-BDD096522954} = {BCD032DD-D088-4F72-B80F-48D0EA845F87} {D708256C-4A68-4B15-AAE5-6EFA41223A70} = {BCD032DD-D088-4F72-B80F-48D0EA845F87} {8DAC59BE-CB96-4F04-909C-56C22E7665EB} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} - {E763DA15-8F4E-446C-99B8-309053C75598} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} + {E763DA15-8F4E-446C-99B8-309053C75598} = {7A331A1C-E2C4-4E37-B0A0-B5AA10661229} {3ADC50B9-2EBB-422A-8424-F9FC67841CA1} = {E763DA15-8F4E-446C-99B8-309053C75598} {05A169C7-4F20-4516-B10A-B13C5649D346} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {3D06E2C9-44F7-408D-802C-42D7E55F08E7} = {05A169C7-4F20-4516-B10A-B13C5649D346} @@ -10725,6 +10901,7 @@ Global {DF4637DA-5F07-4903-8461-4E2DAB235F3C} = {7F99E967-3DC1-4198-9D55-47CD9471D0B6} {AAB50C64-39AA-4AED-8E9C-50D68E7751AD} = {7F99E967-3DC1-4198-9D55-47CD9471D0B6} {9647D8B7-4616-4E05-B258-BAD5CAEEDD38} = {EB5E294B-9ED5-43BF-AFA9-1CD2327F3DC1} + {11BE4471-6C3D-4758-881A-97B6A16F21F6} = {EB5E294B-9ED5-43BF-AFA9-1CD2327F3DC1} {022B4B80-E813-4256-8034-11A68146F4EF} = {E5963C9F-20A6-4385-B364-814D2581FADF} {FF413F1C-A998-4FA2-823F-52AC0916B35C} = {022B4B80-E813-4256-8034-11A68146F4EF} {3A1EC883-EF9C-43E8-95E5-6B527428867B} = {022B4B80-E813-4256-8034-11A68146F4EF} @@ -10797,7 +10974,23 @@ Global {DC349A25-0DBF-4468-99E1-B95C22D3A7EF} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} {94F95276-7CDF-44A8-B159-D09702EF6794} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} {EA7D844B-C180-41C7-9D55-273AD88BF71F} = {94F95276-7CDF-44A8-B159-D09702EF6794} + {7A331A1C-E2C4-4E37-B0A0-B5AA10661229} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} + {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} + {5CDB8ABC-9DD0-4A9F-8948-EED5FFE89F67} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} + {151E6F9E-107B-4DDC-A2B1-95115801FD14} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} + {B43BE3EB-9846-4484-88D8-05165202A0FC} = {DD076DDA-7956-4361-A7D4-2B8025AB3DFD} + {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} = {B43BE3EB-9846-4484-88D8-05165202A0FC} + {2E28881D-A188-47AF-800A-B5877AD8C288} = {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} + {A53696E8-6065-41BA-84FB-E89E0DACFF6C} = {9A8AE587-A3DB-4211-8354-430C4CCBEB9B} + {109C702D-DACE-4F82-A490-15E5AFA94005} = {151E6F9E-107B-4DDC-A2B1-95115801FD14} + {E3C5FAD2-8AB7-47C8-AAFD-8262551A5D11} = {151E6F9E-107B-4DDC-A2B1-95115801FD14} + {90CF4DC6-AC53-459F-9EAB-623A11EADAA3} = {B43BE3EB-9846-4484-88D8-05165202A0FC} + {F18E97AE-3A3F-424D-8DC2-4D001A167F98} = {B43BE3EB-9846-4484-88D8-05165202A0FC} + {8C3E422A-F281-4B93-A567-88C7A1ED0412} = {B43BE3EB-9846-4484-88D8-05165202A0FC} + {EB14F068-AD55-4970-B9B4-1FBE33704243} = {5CDB8ABC-9DD0-4A9F-8948-EED5FFE89F67} {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} + {489020F2-80D9-4468-A5D3-07E785837A5D} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} + {8FED7E65-A7DD-4F13-8980-BF03E77B6C85} = {489020F2-80D9-4468-A5D3-07E785837A5D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/Directory.Build.props b/Directory.Build.props index ffd932e6de9..166dbd64874 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -239,6 +239,7 @@ + diff --git a/eng/Build.props b/eng/Build.props index 5405e216720..8ca48331c88 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -1,6 +1,16 @@ + + + @@ -16,8 +26,11 @@ - - + - + - <_SharedFrameworkAndPackageRef Include="@(_ProjectReferenceProvider->WithMetadataValue('IsAspNetCoreApp','true')->WithMetadataValue('IsPackable', 'true')->Distinct())" /> - <_SharedFrameworkRef Include="@(_ProjectReferenceProvider->WithMetadataValue('IsAspNetCoreApp','true')->WithMetadataValue('IsPackable', 'false')->Distinct())" /> - <_TrimmableProject Include="@(_ProjectReferenceProvider->WithMetadataValue('Trimmable', 'true')->Distinct())" /> + <_ProjectReferenceProvider Include="@(_ProvidesReferenceOrRequiresDelay->WithMetadataValue('IsProjectReferenceProvider','true')->Distinct())" /> + <_RequiresDelayedBuild Include="@(_ProvidesReferenceOrRequiresDelay->WithMetadataValue('RequiresDelayedBuild','true')->Distinct())" /> + <_SharedFrameworkAndPackageRef Include="@(_ProjectReferenceProvider->WithMetadataValue('IsAspNetCoreApp','true')->WithMetadataValue('IsPackable', 'true'))" /> + <_SharedFrameworkRef Include="@(_ProjectReferenceProvider->WithMetadataValue('IsAspNetCoreApp','true')->WithMetadataValue('IsPackable', 'false'))" /> + <_TrimmableProject Include="@(_ProjectReferenceProvider->WithMetadataValue('Trimmable', 'true'))" /> @@ -57,7 +58,7 @@ This file contains a complete list of the assemblies which are part of the shared framework. - This project is generated using the and properties from each .csproj in this repository. + This file is generated using the and properties from each .csproj in this repository. --> @@ -97,6 +98,30 @@ - + + $(MSBuildThisFileDirectory)RequiresDelayedBuildProjects.props + properties. Content may overlap ProjectReferences.csproj + but that is not required (projects that are not project reference providers are also supported). +--> + + + @(_RequiresDelayedBuild->'', '%0A ') + + +]]> + + + + + + + diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 48553fce178..51d1105d9e2 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -178,8 +178,10 @@ and are generated based on the last package release. + + @@ -202,6 +204,7 @@ and are generated based on the last package release. + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 1350e70c8e9..94a43b1c533 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -157,5 +157,7 @@ + + diff --git a/eng/RequiresDelayedBuildProjects.props b/eng/RequiresDelayedBuildProjects.props new file mode 100644 index 00000000000..0951a943c55 --- /dev/null +++ b/eng/RequiresDelayedBuildProjects.props @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index 04869e61b7d..f32bf007b8a 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -3,7 +3,7 @@ This file contains a complete list of the assemblies which are part of the shared framework. - This project is generated using the and properties from each .csproj in this repository. + This file is generated using the and properties from each .csproj in this repository. --> diff --git a/eng/Versions.props b/eng/Versions.props index da6336857df..6ceb8c62438 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -238,11 +238,13 @@ 4.2.1 2.3.0 6.0.0 - 3.18.0 - 2.40.0 - 2.40.0 - 2.40.0 - 2.40.0 + 2.5.0 + 3.18.1 + 2.43.0 + 2.43.0 + 2.43.0 + 2.43.0 + 2.43.0 5.2.0 5.2.0 5.2.0 diff --git a/eng/targets/ResolveReferences.targets b/eng/targets/ResolveReferences.targets index 99835cfe689..7e2bad6d2b5 100644 --- a/eng/targets/ResolveReferences.targets +++ b/eng/targets/ResolveReferences.targets @@ -311,12 +311,18 @@ - + $([MSBuild]::ValueOrDefault($(IsAspNetCoreApp),'false')) $([MSBuild]::ValueOrDefault($(IsPackable),'false')) $([MSBuild]::MakeRelative($(RepoRoot), $(MSBuildProjectFullPath))) $([MSBuild]::ValueOrDefault($(Trimmable),'false')) + + + $([MSBuild]::ValueOrDefault($(IsProjectReferenceProvider),'false')) + + + $([MSBuild]::ValueOrDefault($(RequiresDelayedBuild),'false')) diff --git a/src/BuildAfterTargetingPack/BuildAfterTargetingPack.csproj b/src/BuildAfterTargetingPack/BuildAfterTargetingPack.csproj new file mode 100644 index 00000000000..2c0551b921e --- /dev/null +++ b/src/BuildAfterTargetingPack/BuildAfterTargetingPack.csproj @@ -0,0 +1,94 @@ + + + + true + + + + + + + $(DefaultNetCoreTargetFramework) + + + false + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Grpc/Grpc.slnf b/src/Grpc/Grpc.slnf index d31f1d8a130..7bf99f650bf 100644 --- a/src/Grpc/Grpc.slnf +++ b/src/Grpc/Grpc.slnf @@ -1,11 +1,32 @@ -{ +{ "solution": { "path": "..\\..\\AspNetCore.sln", - "projects" : [ - "src\\Grpc\\test\\InteropTests\\InteropTests.csproj", - "src\\Grpc\\test\\testassets\\InteropWebsite\\InteropWebsite.csproj", - "src\\Grpc\\test\\testassets\\InteropClient\\InteropClient.csproj", + "projects": [ + "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", + "src\\Grpc\\JsonTranscoding\\perf\\Microsoft.AspNetCore.Grpc.Microbenchmarks\\Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj", + "src\\Grpc\\JsonTranscoding\\src\\Microsoft.AspNetCore.Grpc.JsonTranscoding\\Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj", + "src\\Grpc\\JsonTranscoding\\src\\Microsoft.AspNetCore.Grpc.Swagger\\Microsoft.AspNetCore.Grpc.Swagger.csproj", + "src\\Grpc\\JsonTranscoding\\test\\Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests\\Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj", + "src\\Grpc\\JsonTranscoding\\test\\Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests\\Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj", + "src\\Grpc\\JsonTranscoding\\test\\Microsoft.AspNetCore.Grpc.Swagger.Tests\\Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj", + "src\\Grpc\\JsonTranscoding\\test\\testassets\\IntegrationTestsWebsite\\IntegrationTestsWebsite.csproj", + "src\\Grpc\\JsonTranscoding\\test\\testassets\\Sandbox\\Sandbox.csproj", + "src\\Grpc\\Interop\\test\\InteropTests\\InteropTests.csproj", + "src\\Grpc\\Interop\\test\\testassets\\InteropClient\\InteropClient.csproj", + "src\\Grpc\\Interop\\test\\testassets\\InteropWebsite\\InteropWebsite.csproj", + "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", + "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", + "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", + "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", + "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj", + "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj", + "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", + "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", + "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", + "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", + "src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj", + "src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj", "src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Grpc/test/InteropTests/Helpers/ClientProcess.cs b/src/Grpc/Interop/test/InteropTests/Helpers/ClientProcess.cs similarity index 97% rename from src/Grpc/test/InteropTests/Helpers/ClientProcess.cs rename to src/Grpc/Interop/test/InteropTests/Helpers/ClientProcess.cs index 6f50c2d0288..0104584535d 100644 --- a/src/Grpc/test/InteropTests/Helpers/ClientProcess.cs +++ b/src/Grpc/Interop/test/InteropTests/Helpers/ClientProcess.cs @@ -1,11 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Diagnostics; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Internal; using Xunit.Abstractions; diff --git a/src/Grpc/test/InteropTests/Helpers/WebsiteProcess.cs b/src/Grpc/Interop/test/InteropTests/Helpers/WebsiteProcess.cs similarity index 97% rename from src/Grpc/test/InteropTests/Helpers/WebsiteProcess.cs rename to src/Grpc/Interop/test/InteropTests/Helpers/WebsiteProcess.cs index 5936479a49f..cfd8dfb34be 100644 --- a/src/Grpc/test/InteropTests/Helpers/WebsiteProcess.cs +++ b/src/Grpc/Interop/test/InteropTests/Helpers/WebsiteProcess.cs @@ -1,12 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Internal; using Xunit.Abstractions; diff --git a/src/Grpc/test/InteropTests/InteropTests.cs b/src/Grpc/Interop/test/InteropTests/InteropTests.cs similarity index 98% rename from src/Grpc/test/InteropTests/InteropTests.cs rename to src/Grpc/Interop/test/InteropTests/InteropTests.cs index 80bb948655c..d411d130773 100644 --- a/src/Grpc/test/InteropTests/InteropTests.cs +++ b/src/Grpc/Interop/test/InteropTests/InteropTests.cs @@ -1,12 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.IO; -using System.Threading.Tasks; using InteropTests.Helpers; using Microsoft.AspNetCore.Testing; -using Xunit; using Xunit.Abstractions; namespace InteropTests; diff --git a/src/Grpc/test/InteropTests/InteropTests.csproj b/src/Grpc/Interop/test/InteropTests/InteropTests.csproj similarity index 70% rename from src/Grpc/test/InteropTests/InteropTests.csproj rename to src/Grpc/Interop/test/InteropTests/InteropTests.csproj index bb4c663e7ec..f5e9b3a9cbf 100644 --- a/src/Grpc/test/InteropTests/InteropTests.csproj +++ b/src/Grpc/Interop/test/InteropTests/InteropTests.csproj @@ -1,12 +1,12 @@ - + true $(DefaultNetCoreTargetFramework) - - + + + + + + + true + + + true + + + true + + diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonReading.cs b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonReading.cs new file mode 100644 index 00000000000..ad7d12ffcee --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonReading.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Greet; +using Microsoft.AspNetCore.Grpc.JsonTranscoding; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +namespace Microsoft.AspNetCore.Grpc.Microbenchmarks.Json; + +public class JsonReading +{ + private string _requestJson = default!; + private JsonSerializerOptions _serializerOptions = default!; + private JsonParser _jsonFormatter = default!; + + [GlobalSetup] + public void GlobalSetup() + { + _requestJson = (new HelloRequest() { Name = "Hello world" }).ToString(); + _serializerOptions = JsonConverterHelper.CreateSerializerOptions(new JsonContext(new GrpcJsonSettings { WriteIndented = false }, TypeRegistry.Empty)); + _jsonFormatter = new JsonParser(new JsonParser.Settings(recursionLimit: 100)); + } + + [Benchmark] + public void ReadMessage_JsonSerializer() + { + JsonSerializer.Deserialize(_requestJson, typeof(HelloRequest), _serializerOptions); + } + + [Benchmark] + public void ReadMessage_JsonFormatter() + { + _jsonFormatter.Parse(_requestJson, HelloRequest.Descriptor); + } +} diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonWriting.cs b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonWriting.cs new file mode 100644 index 00000000000..f59f512759f --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Json/JsonWriting.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Greet; +using Microsoft.AspNetCore.Grpc.JsonTranscoding; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +namespace Microsoft.AspNetCore.Grpc.Microbenchmarks.Json; + +public class JsonWriting +{ + private HelloRequest _request = default!; + private JsonSerializerOptions _serializerOptions = default!; + private JsonFormatter _jsonFormatter = default!; + + [GlobalSetup] + public void GlobalSetup() + { + _request = new HelloRequest() { Name = "Hello world" }; + _serializerOptions = JsonConverterHelper.CreateSerializerOptions( + new JsonContext(new GrpcJsonSettings { WriteIndented = false }, TypeRegistry.Empty)); + _jsonFormatter = new JsonFormatter(new JsonFormatter.Settings(formatDefaultValues: false)); + } + + [Benchmark] + public void WriteMessage_JsonSerializer() + { + JsonSerializer.Serialize(_request, _serializerOptions); + } + + [Benchmark] + public void WriteMessage_JsonFormatter() + { + _jsonFormatter.Format(_request); + } +} diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj new file mode 100644 index 00000000000..4c8e02e25b8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Microsoft.AspNetCore.Grpc.Microbenchmarks.csproj @@ -0,0 +1,22 @@ + + + $(DefaultNetCoreTargetFramework) + Exe + true + true + false + $(DefineConstants);IS_BENCHMARKS + + + + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Properties/AssemblyInfo.cs b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..09f49228e9e --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/chat.proto b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/chat.proto new file mode 100644 index 00000000000..51f3ff8cbd0 --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/chat.proto @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +package chat; + +// The greeting service definition. +service Chatter { + // Sends a greeting + rpc Chat (stream ChatMessage) returns (stream ChatMessage); +} + +// The chat message. +message ChatMessage { + string name = 1; + string message = 2; +} diff --git a/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/greet.proto b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/greet.proto new file mode 100644 index 00000000000..d1fa8387674 --- /dev/null +++ b/src/Grpc/JsonTranscoding/perf/Microsoft.AspNetCore.Grpc.Microbenchmarks/Proto/greet.proto @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +package greet; + +import "google/protobuf/timestamp.proto"; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply); + rpc SayHellos (HelloRequest) returns (stream HelloReply); +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; + google.protobuf.Timestamp timestamp = 2; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs new file mode 100644 index 00000000000..e5f8769edad --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding; + +/// +/// Provides settings for serializing JSON. +/// +public sealed class GrpcJsonSettings +{ + /// + /// Whether fields which would otherwise not be included in the formatted data + /// should be formatted even when the value is not present, or has the default value. + /// This option only affects fields which don't support "presence" (e.g. + /// singular non-optional proto3 primitive fields). + /// + public bool IgnoreDefaultValues { get; set; } + + /// + /// Gets or sets a value that indicates whether values are written as integers instead of strings. + /// Default value is false. + /// + public bool WriteEnumsAsIntegers { get; set; } + + /// + /// Gets or sets a value that indicates whether and values are written as strings instead of numbers. + /// Default value is false. + /// + public bool WriteInt64sAsStrings { get; set; } + + /// + /// Gets or sets a value that indicates whether JSON should use pretty printing. + /// Default value is false. + /// + public bool WriteIndented { get; set; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingMetadata.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingMetadata.cs new file mode 100644 index 00000000000..5f17949dbb3 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingMetadata.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Api; +using Google.Protobuf.Reflection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding; + +/// +/// Metadata for a gRPC JSON transcoding endpoint. +/// +public sealed class GrpcJsonTranscodingMetadata +{ + /// + /// Creates a new instance of with the provided Protobuf + /// and . + /// + /// The Protobuf . + /// The . + public GrpcJsonTranscodingMetadata(MethodDescriptor methodDescriptor, HttpRule httpRule) + { + MethodDescriptor = methodDescriptor; + HttpRule = httpRule; + } + + /// + /// Gets the Protobuf . + /// + public MethodDescriptor MethodDescriptor { get; } + + /// + /// Gets the . + /// + public HttpRule HttpRule { get; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingOptions.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingOptions.cs new file mode 100644 index 00000000000..8d7d8a41321 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingOptions.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf.Reflection; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding; + +/// +/// Options used to configure gRPC JSON transcoding service instances. +/// +public sealed class GrpcJsonTranscodingOptions +{ + private readonly Lazy _unaryOptions; + private readonly Lazy _serverStreamingOptions; + + public GrpcJsonTranscodingOptions() + { + _unaryOptions = new Lazy( + () => JsonConverterHelper.CreateSerializerOptions(new JsonContext(JsonSettings, TypeRegistry)), + LazyThreadSafetyMode.ExecutionAndPublication); + _serverStreamingOptions = new Lazy( + () => JsonConverterHelper.CreateSerializerOptions(new JsonContext(JsonSettings, TypeRegistry), isStreamingOptions: true), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + internal JsonSerializerOptions UnarySerializerOptions => _unaryOptions.Value; + internal JsonSerializerOptions ServerStreamingSerializerOptions => _serverStreamingOptions.Value; + + /// + /// Gets or sets the used to lookup types from type names. + /// + public TypeRegistry TypeRegistry { get; set; } = TypeRegistry.Empty; + + /// + /// Gets or sets the used to serialize messages. + /// + public GrpcJsonSettings JsonSettings { get; set; } = new GrpcJsonSettings(); +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingServiceExtensions.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingServiceExtensions.cs new file mode 100644 index 00000000000..98934e63556 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonTranscodingServiceExtensions.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Microsoft.AspNetCore.Grpc.JsonTranscoding; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for the gRPC JSON transcoding services. +/// +public static class GrpcJsonTranscodingServiceExtensions +{ + /// + /// Adds gRPC JSON transcoding services to the specified . + /// + /// The . + /// The same instance of the for chaining. + public static IGrpcServerBuilder AddJsonTranscoding(this IGrpcServerBuilder grpcBuilder) + { + if (grpcBuilder == null) + { + throw new ArgumentNullException(nameof(grpcBuilder)); + } + + grpcBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IServiceMethodProvider<>), typeof(JsonTranscodingServiceMethodProvider<>))); + + return grpcBuilder; + } + + /// + /// Adds gRPC JSON transcoding services to the specified . + /// + /// The . + /// An to configure the provided . + /// The same instance of the for chaining. + public static IGrpcServerBuilder AddJsonTranscoding(this IGrpcServerBuilder grpcBuilder, Action configureOptions) + { + if (grpcBuilder == null) + { + throw new ArgumentNullException(nameof(grpcBuilder)); + } + + grpcBuilder.Services.Configure(configureOptions); + return grpcBuilder.AddJsonTranscoding(); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiProviderSeviceBinder.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiProviderSeviceBinder.cs new file mode 100644 index 00000000000..3941ea7b5ea --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiProviderSeviceBinder.cs @@ -0,0 +1,294 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Google.Api; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Grpc.Shared; +using Grpc.Shared.Server; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Logging; +using MethodOptions = global::Grpc.Shared.Server.MethodOptions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; + +internal sealed partial class JsonTranscodingProviderServiceBinder : ServiceBinderBase where TService : class +{ + private delegate (RequestDelegate RequestDelegate, List Metadata) CreateRequestDelegate( + Method method, + string httpVerb, + HttpRule httpRule, + MethodDescriptor methodDescriptor, + CallHandlerDescriptorInfo descriptorInfo, + MethodOptions methodOptions); + + private readonly ServiceMethodProviderContext _context; + private readonly IServiceInvokerResolver _invokerResolver; + private readonly ServiceDescriptor _serviceDescriptor; + private readonly GrpcServiceOptions _globalOptions; + private readonly GrpcServiceOptions _serviceOptions; + private readonly IGrpcServiceActivator _serviceActivator; + private readonly GrpcJsonTranscodingOptions _JsonTranscodingOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + + internal JsonTranscodingProviderServiceBinder( + ServiceMethodProviderContext context, + IServiceInvokerResolver invokerResolver, + ServiceDescriptor serviceDescriptor, + GrpcServiceOptions globalOptions, + GrpcServiceOptions serviceOptions, + ILoggerFactory loggerFactory, + IGrpcServiceActivator serviceActivator, + GrpcJsonTranscodingOptions JsonTranscodingOptions) + { + _context = context; + _invokerResolver = invokerResolver; + _serviceDescriptor = serviceDescriptor; + _globalOptions = globalOptions; + _serviceOptions = serviceOptions; + _serviceActivator = serviceActivator; + _JsonTranscodingOptions = JsonTranscodingOptions; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger>(); + } + + public override void AddMethod(Method method, ClientStreamingServerMethod handler) + { + if (TryGetMethodDescriptor(method.Name, out var methodDescriptor) && + ServiceDescriptorHelpers.TryGetHttpRule(methodDescriptor, out _)) + { + Log.StreamingMethodNotSupported(_logger, method.Name, method.ServiceName); + } + } + + public override void AddMethod(Method method, DuplexStreamingServerMethod handler) + { + if (TryGetMethodDescriptor(method.Name, out var methodDescriptor) && + ServiceDescriptorHelpers.TryGetHttpRule(methodDescriptor, out _)) + { + Log.StreamingMethodNotSupported(_logger, method.Name, method.ServiceName); + } + } + + public override void AddMethod(Method method, ServerStreamingServerMethod handler) + { + if (TryGetMethodDescriptor(method.Name, out var methodDescriptor)) + { + if (ServiceDescriptorHelpers.TryGetHttpRule(methodDescriptor, out var httpRule)) + { + LogMethodHttpRule(method, httpRule); + ProcessHttpRule(method, methodDescriptor, httpRule, CreateServerStreamingRequestDelegate); + } + else + { + // Consider setting to enable mapping to methods without HttpRule + // AddMethodCore(method, method.FullName, "GET", string.Empty, string.Empty, methodDescriptor); + } + } + else + { + Log.MethodDescriptorNotFound(_logger, method.Name, typeof(TService)); + } + } + + public override void AddMethod(Method method, UnaryServerMethod handler) + { + if (TryGetMethodDescriptor(method.Name, out var methodDescriptor)) + { + if (ServiceDescriptorHelpers.TryGetHttpRule(methodDescriptor, out var httpRule)) + { + LogMethodHttpRule(method, httpRule); + ProcessHttpRule(method, methodDescriptor, httpRule, CreateUnaryRequestDelegate); + } + else + { + // Consider setting to enable mapping to methods without HttpRule + // AddMethodCore(method, method.FullName, "GET", string.Empty, string.Empty, methodDescriptor); + } + } + else + { + Log.MethodDescriptorNotFound(_logger, method.Name, typeof(TService)); + } + } + + private void LogMethodHttpRule(IMethod method, HttpRule httpRule) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + Log.HttpRuleFound(_logger, method.Name, method.ServiceName, httpRule.ToString()); + } + } + + private void ProcessHttpRule( + Method method, + MethodDescriptor methodDescriptor, + HttpRule httpRule, + CreateRequestDelegate createRequestDelegate) + where TRequest : class + where TResponse : class + { + if (ServiceDescriptorHelpers.TryResolvePattern(httpRule, out var pattern, out var httpVerb)) + { + AddMethodCore(method, httpRule, pattern, httpVerb, httpRule.Body, httpRule.ResponseBody, methodDescriptor, createRequestDelegate); + } + + foreach (var additionalRule in httpRule.AdditionalBindings) + { + ProcessHttpRule(method, methodDescriptor, additionalRule, createRequestDelegate); + } + } + + private (RequestDelegate RequestDelegate, List Metadata) CreateUnaryRequestDelegate( + Method method, + string httpVerb, + HttpRule httpRule, + MethodDescriptor methodDescriptor, + CallHandlerDescriptorInfo descriptorInfo, + MethodOptions methodOptions) + where TRequest : class + where TResponse : class + { + var (invoker, metadata) = _invokerResolver.CreateModelCore>( + method.Name, + new[] { typeof(TRequest), typeof(ServerCallContext) }, + httpVerb, + httpRule, + methodDescriptor); + + var methodInvoker = new UnaryServerMethodInvoker(invoker, method, methodOptions, _serviceActivator); + var callHandler = new UnaryServerCallHandler( + methodInvoker, + _loggerFactory, + descriptorInfo, + _JsonTranscodingOptions.UnarySerializerOptions); + + return (callHandler.HandleCallAsync, metadata); + } + + private (RequestDelegate RequestDelegate, List Metadata) CreateServerStreamingRequestDelegate( + Method method, + string httpVerb, + HttpRule httpRule, + MethodDescriptor methodDescriptor, + CallHandlerDescriptorInfo descriptorInfo, + MethodOptions methodOptions) + where TRequest : class + where TResponse : class + { + var (invoker, metadata) = _invokerResolver.CreateModelCore>( + method.Name, + new[] { typeof(TRequest), typeof(IServerStreamWriter), typeof(ServerCallContext) }, + httpVerb, + httpRule, + methodDescriptor); + + var methodInvoker = new ServerStreamingServerMethodInvoker(invoker, method, methodOptions, _serviceActivator); + var callHandler = new ServerStreamingServerCallHandler( + methodInvoker, + _loggerFactory, + descriptorInfo, + _JsonTranscodingOptions.ServerStreamingSerializerOptions); + + return (callHandler.HandleCallAsync, metadata); + } + + private void AddMethodCore( + Method method, + HttpRule httpRule, + string pattern, + string httpVerb, + string body, + string responseBody, + MethodDescriptor methodDescriptor, + CreateRequestDelegate createRequestDelegate) + where TRequest : class + where TResponse : class + { + try + { + var (routePattern, descriptorInfo) = ParseRoute(pattern, body, responseBody, methodDescriptor); + var methodOptions = MethodOptions.Create(new[] { _globalOptions, _serviceOptions }); + + var (requestDelegate, metadata) = createRequestDelegate(method, httpVerb, httpRule, methodDescriptor, descriptorInfo, methodOptions); + + _context.AddMethod(method, routePattern, metadata, requestDelegate); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error binding {method.Name} on {typeof(TService).Name} to HTTP API.", ex); + } + } + + private static (RoutePattern routePattern, CallHandlerDescriptorInfo descriptorInfo) ParseRoute(string pattern, string body, string responseBody, MethodDescriptor methodDescriptor) + { + if (!pattern.StartsWith('/')) + { + // This validation is consistent with grpc-gateway code generation. + // We should match their validation to be a good member of the eco-system. + throw new InvalidOperationException($"Path template '{pattern}' must start with a '/'."); + } + + var routePattern = RoutePatternFactory.Parse(pattern); + return (RoutePatternFactory.Parse(pattern), CreateDescriptorInfo(body, responseBody, methodDescriptor, routePattern)); + } + + private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, RoutePattern routePattern) + { + var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routePattern, methodDescriptor.InputType); + + var bodyDescriptor = ServiceDescriptorHelpers.ResolveBodyDescriptor(body, typeof(TService), methodDescriptor); + + FieldDescriptor? responseBodyDescriptor = null; + if (!string.IsNullOrEmpty(responseBody)) + { + responseBodyDescriptor = methodDescriptor.OutputType.FindFieldByName(responseBody); + if (responseBodyDescriptor == null) + { + throw new InvalidOperationException($"Couldn't find matching field for response body '{responseBody}' on {methodDescriptor.OutputType.Name}."); + } + } + + var descriptorInfo = new CallHandlerDescriptorInfo( + responseBodyDescriptor, + bodyDescriptor?.Descriptor, + bodyDescriptor?.IsDescriptorRepeated ?? false, + bodyDescriptor?.FieldDescriptors, + routeParameterDescriptors); + return descriptorInfo; + } + + private bool TryGetMethodDescriptor(string methodName, [NotNullWhen(true)]out MethodDescriptor? methodDescriptor) + { + for (var i = 0; i < _serviceDescriptor.Methods.Count; i++) + { + var method = _serviceDescriptor.Methods[i]; + if (method.Name == methodName) + { + methodDescriptor = method; + return true; + } + } + + methodDescriptor = null; + return false; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Unable to find method descriptor for {MethodName} on {ServiceType}.", EventName = "MethodDescriptorNotFound")] + public static partial void MethodDescriptorNotFound(ILogger logger, string methodName, Type serviceType); + + [LoggerMessage(2, LogLevel.Warning, "Unable to bind {MethodName} on {ServiceName} to gRPC JSON transcoding. Client and bidirectional streaming methods are not supported.", EventName = "StreamingMethodNotSupported")] + public static partial void StreamingMethodNotSupported(ILogger logger, string methodName, string serviceName); + + [LoggerMessage(3, LogLevel.Trace, "Found HttpRule mapping. Method {MethodName} on {ServiceName}. HttpRule payload: {HttpRulePayload}", EventName = "HttpRuleFound", SkipEnabledCheck = true)] + public static partial void HttpRuleFound(ILogger logger, string methodName, string serviceName, string httpRulePayload); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiServiceMethodProvider.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiServiceMethodProvider.cs new file mode 100644 index 00000000000..6b687269525 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/HttpApiServiceMethodProvider.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Shared; +using Grpc.Shared.Server; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; + +internal sealed partial class JsonTranscodingServiceMethodProvider : IServiceMethodProvider where TService : class +{ + private readonly ILogger> _logger; + private readonly GrpcServiceOptions _globalOptions; + private readonly GrpcServiceOptions _serviceOptions; + private readonly GrpcJsonTranscodingOptions _JsonTranscodingOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly IGrpcServiceActivator _serviceActivator; + + public JsonTranscodingServiceMethodProvider( + ILoggerFactory loggerFactory, + IOptions globalOptions, + IOptions> serviceOptions, + IGrpcServiceActivator serviceActivator, + IOptions JsonTranscodingOptions) + { + _logger = loggerFactory.CreateLogger>(); + _globalOptions = globalOptions.Value; + _serviceOptions = serviceOptions.Value; + _JsonTranscodingOptions = JsonTranscodingOptions.Value; + _loggerFactory = loggerFactory; + _serviceActivator = serviceActivator; + } + + public void OnServiceMethodDiscovery(ServiceMethodProviderContext context) + { + var bindMethodInfo = BindMethodFinder.GetBindMethod(typeof(TService)); + + // Invoke BindService(ServiceBinderBase, BaseType) + if (bindMethodInfo != null) + { + // The second parameter is always the service base type + var serviceParameter = bindMethodInfo.GetParameters()[1]; + + ServiceDescriptor? serviceDescriptor = null; + try + { + serviceDescriptor = ServiceDescriptorHelpers.GetServiceDescriptor(bindMethodInfo.DeclaringType!); + } + catch (Exception ex) + { + Log.ServiceDescriptorError(_logger, typeof(TService), ex); + } + + if (serviceDescriptor != null) + { + var binder = new JsonTranscodingProviderServiceBinder( + context, + new ReflectionServiceInvokerResolver(serviceParameter.ParameterType), + serviceDescriptor, + _globalOptions, + _serviceOptions, + _loggerFactory, + _serviceActivator, + _JsonTranscodingOptions); + + try + { + bindMethodInfo.Invoke(null, new object?[] { binder, null }); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error binding gRPC service '{typeof(TService).Name}'.", ex); + } + } + } + else + { + Log.BindMethodNotFound(_logger, typeof(TService)); + } + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Could not find bind method for {ServiceType}.", EventName = "BindMethodNotFound")] + public static partial void BindMethodNotFound(ILogger logger, Type serviceType); + + [LoggerMessage(2, LogLevel.Warning, "Error getting service descriptor for {ServiceType}.", EventName = "ServiceDescriptorError")] + public static partial void ServiceDescriptorError(ILogger logger, Type serviceType, Exception ex); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/IServiceInvokerResolver.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/IServiceInvokerResolver.cs new file mode 100644 index 00000000000..0d05cc2a887 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/IServiceInvokerResolver.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Api; +using Google.Protobuf.Reflection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; + +internal interface IServiceInvokerResolver where TService : class +{ + /// + /// Creates a service invoker delegate and associated metadata using the service type, method name, and HTTP binding. + /// + (TDelegate invoker, List metadata) CreateModelCore( + string methodName, + Type[] methodParameters, + string verb, + HttpRule httpRule, + MethodDescriptor methodDescriptor) where TDelegate : Delegate; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/ReflectionServiceInvokerResolver.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/ReflectionServiceInvokerResolver.cs new file mode 100644 index 00000000000..3813b686e97 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/ReflectionServiceInvokerResolver.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Google.Api; +using Google.Protobuf.Reflection; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; + +internal sealed class ReflectionServiceInvokerResolver + : IServiceInvokerResolver where TService : class +{ + private readonly Type _declaringType; + + public ReflectionServiceInvokerResolver(Type declaringType) + { + _declaringType = declaringType; + } + + public (TDelegate invoker, List metadata) CreateModelCore( + string methodName, + Type[] methodParameters, + string verb, + HttpRule httpRule, + MethodDescriptor methodDescriptor) where TDelegate : Delegate + { + var handlerMethod = GetMethod(methodName, methodParameters); + + if (handlerMethod == null) + { + throw new InvalidOperationException($"Could not find '{methodName}' on {typeof(TService)}."); + } + + var invoker = (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), handlerMethod); + + var metadata = new List(); + // Add type metadata first so it has a lower priority + metadata.AddRange(typeof(TService).GetCustomAttributes(inherit: true)); + // Add method metadata last so it has a higher priority + metadata.AddRange(handlerMethod.GetCustomAttributes(inherit: true)); + metadata.Add(new HttpMethodMetadata(new[] { verb })); + + // Add protobuf service method descriptor. + // Is used by swagger generation to identify gRPC JSON transcoding APIs. + metadata.Add(new GrpcJsonTranscodingMetadata(methodDescriptor, httpRule)); + + return (invoker, metadata); + } + + private MethodInfo? GetMethod(string methodName, Type[] methodParameters) + { + Type? currentType = typeof(TService); + while (currentType != null) + { + var matchingMethod = currentType.GetMethod( + methodName, + BindingFlags.Public | BindingFlags.Instance, + binder: null, + types: methodParameters, + modifiers: null); + + if (matchingMethod == null) + { + return null; + } + + // Validate that the method overrides the virtual method on the base service type. + // If there is a method with the same name it will hide the base method. Ignore it, + // and continue searching on the base type. + if (matchingMethod.IsVirtual) + { + var baseDefinitionMethod = matchingMethod.GetBaseDefinition(); + if (baseDefinitionMethod != null && baseDefinitionMethod.DeclaringType == _declaringType) + { + return matchingMethod; + } + } + + currentType = currentType.BaseType; + } + + return null; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs new file mode 100644 index 00000000000..fc31a28f01e --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Google.Protobuf.Reflection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; + +internal sealed class CallHandlerDescriptorInfo +{ + public CallHandlerDescriptorInfo( + FieldDescriptor? responseBodyDescriptor, + MessageDescriptor? bodyDescriptor, + bool bodyDescriptorRepeated, + List? bodyFieldDescriptors, + Dictionary> routeParameterDescriptors) + { + ResponseBodyDescriptor = responseBodyDescriptor; + BodyDescriptor = bodyDescriptor; + BodyDescriptorRepeated = bodyDescriptorRepeated; + BodyFieldDescriptors = bodyFieldDescriptors; + RouteParameterDescriptors = routeParameterDescriptors; + if (BodyFieldDescriptors != null) + { + BodyFieldDescriptorsPath = string.Join('.', BodyFieldDescriptors.Select(d => d.Name)); + } + PathDescriptorsCache = new ConcurrentDictionary?>(); + } + + public FieldDescriptor? ResponseBodyDescriptor { get; } + public MessageDescriptor? BodyDescriptor { get; } + [MemberNotNullWhen(true, nameof(BodyFieldDescriptors), nameof(BodyFieldDescriptorsPath))] + public bool BodyDescriptorRepeated { get; } + public List? BodyFieldDescriptors { get; } + public Dictionary> RouteParameterDescriptors { get; } + public ConcurrentDictionary?> PathDescriptorsCache { get; } + public string? BodyFieldDescriptorsPath { get; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs new file mode 100644 index 00000000000..aeb030dd777 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Grpc.AspNetCore.Server; +using Grpc.Core; +using Grpc.Shared.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; + +internal abstract class ServerCallHandlerBase + where TService : class + where TRequest : class + where TResponse : class +{ + private const string LoggerName = "Grpc.AspNetCore.Grpc.JsonTranscoding.ServerCallHandler"; + + protected ServerMethodInvokerBase MethodInvoker { get; } + public CallHandlerDescriptorInfo DescriptorInfo { get; } + public JsonSerializerOptions SerializerOptions { get; } + protected ILogger Logger { get; } + + protected ServerCallHandlerBase( + ServerMethodInvokerBase methodInvoker, + ILoggerFactory loggerFactory, + CallHandlerDescriptorInfo descriptorInfo, + JsonSerializerOptions serializerOptions) + { + MethodInvoker = methodInvoker; + DescriptorInfo = descriptorInfo; + SerializerOptions = serializerOptions; + Logger = loggerFactory.CreateLogger(LoggerName); + } + + public Task HandleCallAsync(HttpContext httpContext) + { + var serverCallContext = new JsonTranscodingServerCallContext(httpContext, MethodInvoker.Options, MethodInvoker.Method, DescriptorInfo, Logger); + httpContext.Features.Set(serverCallContext); + + try + { + serverCallContext.Initialize(); + + var handleCallTask = HandleCallAsyncCore(httpContext, serverCallContext); + + if (handleCallTask.IsCompletedSuccessfully) + { + return Task.CompletedTask; + } + else + { + return AwaitHandleCall(serverCallContext, MethodInvoker.Method, IsStreaming, SerializerOptions, handleCallTask); + } + } + catch (Exception ex) + { + return serverCallContext.ProcessHandlerErrorAsync(ex, MethodInvoker.Method.Name, IsStreaming, SerializerOptions); + } + + static async Task AwaitHandleCall(JsonTranscodingServerCallContext serverCallContext, Method method, bool isStreaming, JsonSerializerOptions serializerOptions, Task handleCall) + { + try + { + await handleCall; + } + catch (Exception ex) + { + await serverCallContext.ProcessHandlerErrorAsync(ex, method.Name, isStreaming, serializerOptions); + } + } + } + + protected abstract Task HandleCallAsyncCore(HttpContext httpContext, JsonTranscodingServerCallContext serverCallContext); + + protected virtual bool IsStreaming => false; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerStreamingServerCallHandler.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerStreamingServerCallHandler.cs new file mode 100644 index 00000000000..a5fc2981c47 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerStreamingServerCallHandler.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Grpc.Shared.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; + +internal sealed class ServerStreamingServerCallHandler : ServerCallHandlerBase + where TService : class + where TRequest : class + where TResponse : class +{ + private readonly ServerStreamingServerMethodInvoker _invoker; + + public ServerStreamingServerCallHandler( + ServerStreamingServerMethodInvoker unaryMethodInvoker, + ILoggerFactory loggerFactory, + CallHandlerDescriptorInfo descriptorInfo, + JsonSerializerOptions options) : base(unaryMethodInvoker, loggerFactory, descriptorInfo, options) + { + _invoker = unaryMethodInvoker; + } + + protected override async Task HandleCallAsyncCore(HttpContext httpContext, JsonTranscodingServerCallContext serverCallContext) + { + // Decode request + var request = await JsonRequestHelpers.ReadMessage(serverCallContext, SerializerOptions); + + var streamWriter = new HttpContextStreamWriter(serverCallContext, SerializerOptions); + try + { + await _invoker.Invoke(httpContext, serverCallContext, request, streamWriter); + } + finally + { + streamWriter.Complete(); + } + } + + protected override bool IsStreaming => true; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs new file mode 100644 index 00000000000..b8c390ca346 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Grpc.Core; +using Grpc.Shared.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; + +internal sealed class UnaryServerCallHandler : ServerCallHandlerBase + where TService : class + where TRequest : class + where TResponse : class +{ + private readonly UnaryServerMethodInvoker _invoker; + + public UnaryServerCallHandler( + UnaryServerMethodInvoker unaryMethodInvoker, + ILoggerFactory loggerFactory, + CallHandlerDescriptorInfo descriptorInfo, + JsonSerializerOptions options) : base(unaryMethodInvoker, loggerFactory, descriptorInfo, options) + { + _invoker = unaryMethodInvoker; + } + + protected override async Task HandleCallAsyncCore(HttpContext httpContext, JsonTranscodingServerCallContext serverCallContext) + { + var request = await JsonRequestHelpers.ReadMessage(serverCallContext, SerializerOptions); + + var response = await _invoker.Invoke(httpContext, serverCallContext, request); + + if (serverCallContext.Status.StatusCode != StatusCode.OK) + { + throw new RpcException(serverCallContext.Status); + } + + if (response == null) + { + // This is consistent with Grpc.Core when a null value is returned + throw new RpcException(new Status(StatusCode.Cancelled, "No message returned from method.")); + } + + serverCallContext.EnsureResponseHeaders(); + + await JsonRequestHelpers.SendMessage(serverCallContext, SerializerOptions, response); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CommonGrpcProtocolHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CommonGrpcProtocolHelpers.cs new file mode 100644 index 00000000000..37efa143475 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CommonGrpcProtocolHelpers.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static class CommonGrpcProtocolHelpers +{ + public static string ConvertToRpcExceptionMessage(Exception ex) + { + // RpcException doesn't allow for an inner exception. To ensure the user is getting enough information about the + // error we will concatenate any inner exception messages together. + return ex.InnerException == null ? $"{ex.GetType().Name}: {ex.Message}" : BuildErrorMessage(ex); + } + + private static string BuildErrorMessage(Exception ex) + { + // Concatenate inner exceptions messages together. + var sb = new StringBuilder(); + var first = true; + Exception? current = ex; + do + { + if (!first) + { + sb.Append(' '); + } + else + { + first = false; + } + sb.Append(current.GetType().Name); + sb.Append(": "); + sb.Append(current.Message); + } + while ((current = current.InnerException) != null); + + return sb.ToString(); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/ErrorMessageHelper.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/ErrorMessageHelper.cs new file mode 100644 index 00000000000..a4fb0701c15 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/ErrorMessageHelper.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static class ErrorMessageHelper +{ + internal static string BuildErrorMessage(string message, Exception exception, bool? includeExceptionDetails) + { + if (includeExceptionDetails ?? false) + { + return message + " " + CommonGrpcProtocolHelpers.ConvertToRpcExceptionMessage(exception); + } + + return message; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolConstants.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolConstants.cs new file mode 100644 index 00000000000..b3c660e309c --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolConstants.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static class GrpcProtocolConstants +{ + internal const string TimeoutHeader = "grpc-timeout"; + internal const string MessageEncodingHeader = "grpc-encoding"; + internal const string MessageAcceptEncodingHeader = "grpc-accept-encoding"; + internal static readonly ReadOnlyMemory StreamingDelimiter = new byte[] { (byte)'\n' }; + + internal static readonly HashSet FilteredHeaders = new HashSet(StringComparer.OrdinalIgnoreCase) + { + MessageEncodingHeader, + MessageAcceptEncodingHeader, + TimeoutHeader, + HeaderNames.ContentType, + HeaderNames.TE, + HeaderNames.Host, + HeaderNames.AcceptEncoding + }; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolHelpers.cs new file mode 100644 index 00000000000..4d41086cdf8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcProtocolHelpers.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static class GrpcProtocolHelpers +{ + public static byte[] ParseBinaryHeader(string base64) + { + string decodable; + switch (base64.Length % 4) + { + case 0: + // base64 has the required padding + decodable = base64; + break; + case 2: + // 2 chars padding + decodable = base64 + "=="; + break; + case 3: + // 3 chars padding + decodable = base64 + "="; + break; + default: + // length%4 == 1 should be illegal + throw new FormatException("Invalid base64 header value"); + } + + return Convert.FromBase64String(decodable); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcServerLog.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcServerLog.cs new file mode 100644 index 00000000000..3678496f0f2 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/GrpcServerLog.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static partial class GrpcServerLog +{ + [LoggerMessage(1, LogLevel.Information, "Request content-type of '{ContentType}' is not supported.", EventName = "UnsupportedRequestContentType")] + public static partial void UnsupportedRequestContentType(ILogger logger, string? contentType); + + [LoggerMessage(2, LogLevel.Error, "Error when executing service method '{ServiceMethod}'.", EventName = "ErrorExecutingServiceMethod")] + public static partial void ErrorExecutingServiceMethod(ILogger logger, string serviceMethod, Exception ex); + + [LoggerMessage(3, LogLevel.Information, "Error status code '{StatusCode}' with detail '{Detail}' raised.", EventName = "RpcConnectionError")] + public static partial void RpcConnectionError(ILogger logger, StatusCode statusCode, string detail); + + [LoggerMessage(4, LogLevel.Debug, "Reading message.", EventName = "ReadingMessage")] + public static partial void ReadingMessage(ILogger logger); + + [LoggerMessage(5, LogLevel.Trace, "Deserializing to '{MessageType}'.", EventName = "DeserializingMessage")] + public static partial void DeserializingMessage(ILogger logger, Type messageType); + + [LoggerMessage(6, LogLevel.Trace, "Received message.", EventName = "ReceivedMessage")] + public static partial void ReceivedMessage(ILogger logger); + + [LoggerMessage(7, LogLevel.Information, "Error reading message.", EventName = "ErrorReadingMessage")] + public static partial void ErrorReadingMessage(ILogger logger, Exception ex); + + [LoggerMessage(8, LogLevel.Debug, "Sending message.", EventName = "SendingMessage")] + public static partial void SendingMessage(ILogger logger); + + [LoggerMessage(9, LogLevel.Debug, "Message sent.", EventName = "MessageSent")] + public static partial void MessageSent(ILogger logger); + + [LoggerMessage(10, LogLevel.Information, "Error sending message.", EventName = "ErrorSendingMessage")] + public static partial void ErrorSendingMessage(ILogger logger, Exception ex); + + [LoggerMessage(11, LogLevel.Trace, "Serialized '{MessageType}'.", EventName = "SerializedMessage")] + public static partial void SerializedMessage(ILogger logger, Type messageType); +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpApiServerCallContext.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpApiServerCallContext.cs new file mode 100644 index 00000000000..69185591e9f --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpApiServerCallContext.cs @@ -0,0 +1,238 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Grpc.AspNetCore.Server; +using Grpc.Core; +using Grpc.Shared; +using Grpc.Shared.Server; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal sealed class JsonTranscodingServerCallContext : ServerCallContext, IServerCallContextFeature +{ + // TODO(JamesNK): Remove nullable override after Grpc.Core.Api update + private static readonly AuthContext UnauthenticatedContext = new AuthContext(null!, new Dictionary>()); + + private readonly IMethod _method; + + public HttpContext HttpContext { get; } + public MethodOptions Options { get; } + public CallHandlerDescriptorInfo DescriptorInfo { get; } + public bool IsJsonRequestContent { get; private set; } + // Default request encoding to UTF8 so an encoding is available + // if the request sends an invalid/unsupported encoding. + public Encoding RequestEncoding { get; private set; } = Encoding.UTF8; + + internal ILogger Logger { get; } + + private string? _peer; + private Metadata? _requestHeaders; + private AuthContext? _authContext; + + public JsonTranscodingServerCallContext(HttpContext httpContext, MethodOptions options, IMethod method, CallHandlerDescriptorInfo descriptorInfo, ILogger logger) + { + HttpContext = httpContext; + Options = options; + _method = method; + DescriptorInfo = descriptorInfo; + Logger = logger; + } + + public void Initialize() + { + IsJsonRequestContent = JsonRequestHelpers.HasJsonContentType(HttpContext.Request, out var charset); + RequestEncoding = JsonRequestHelpers.GetEncodingFromCharset(charset) ?? Encoding.UTF8; + + // HttpContext.Items is publically exposed as ServerCallContext.UserState. + // Because this is a custom ServerCallContext, HttpContext must be added to UserState so GetHttpContext() continues to work. + // https://github.com/grpc/grpc-dotnet/blob/7ef184f3c4cd62fbc3cde55e4bb3e16b58258ca1/src/Grpc.AspNetCore.Server/ServerCallContextExtensions.cs#L53-L61 + HttpContext.Items["__HttpContext"] = HttpContext; + } + + public ServerCallContext ServerCallContext => this; + + protected override string MethodCore => _method.FullName; + + protected override string HostCore => HttpContext.Request.Host.Value; + + protected override string PeerCore + { + get + { + // Follows the standard at https://github.com/grpc/grpc/blob/master/doc/naming.md + if (_peer == null) + { + _peer = BuildPeer(); + } + + return _peer; + } + } + + private string BuildPeer() + { + var connection = HttpContext.Connection; + if (connection.RemoteIpAddress != null) + { + switch (connection.RemoteIpAddress.AddressFamily) + { + case AddressFamily.InterNetwork: + return $"ipv4:{connection.RemoteIpAddress}:{connection.RemotePort}"; + case AddressFamily.InterNetworkV6: + return $"ipv6:[{connection.RemoteIpAddress}]:{connection.RemotePort}"; + default: + // TODO(JamesNK) - Test what should be output when used with UDS and named pipes + return $"unknown:{connection.RemoteIpAddress}:{connection.RemotePort}"; + } + } + else + { + return "unknown"; // Match Grpc.Core + } + } + + internal async Task ProcessHandlerErrorAsync(Exception ex, string method, bool isStreaming, JsonSerializerOptions options) + { + Status status; + if (ex is RpcException rpcException) + { + // RpcException is thrown by client code to modify the status returned from the server. + // Log the status and detail. Don't log the exception to reduce log verbosity. + GrpcServerLog.RpcConnectionError(Logger, rpcException.StatusCode, rpcException.Status.Detail); + + status = rpcException.Status; + } + else + { + GrpcServerLog.ErrorExecutingServiceMethod(Logger, method, ex); + + var message = ErrorMessageHelper.BuildErrorMessage("Exception was thrown by handler.", ex, Options.EnableDetailedErrors); + + // Note that the exception given to status won't be returned to the client. + // It is still useful to set in case an interceptor accesses the status on the server. + status = new Status(StatusCode.Unknown, message, ex); + } + + await JsonRequestHelpers.SendErrorResponse(HttpContext.Response, RequestEncoding, status, options); + if (isStreaming) + { + await HttpContext.Response.Body.WriteAsync(GrpcProtocolConstants.StreamingDelimiter); + } + } + + // Deadline returns max value when there isn't a deadline. + protected override DateTime DeadlineCore => DateTime.MaxValue; + + protected override Metadata RequestHeadersCore + { + get + { + if (_requestHeaders == null) + { + _requestHeaders = new Metadata(); + + foreach (var header in HttpContext.Request.Headers) + { + // gRPC metadata contains a subset of the request headers + // Filter out pseudo headers (start with :) and other known headers + if (header.Key.StartsWith(':') || GrpcProtocolConstants.FilteredHeaders.Contains(header.Key)) + { + continue; + } + else if (header.Key.EndsWith(Metadata.BinaryHeaderSuffix, StringComparison.OrdinalIgnoreCase)) + { + _requestHeaders.Add(header.Key, GrpcProtocolHelpers.ParseBinaryHeader(header.Value!)); + } + else + { + _requestHeaders.Add(header.Key, header.Value!); + } + } + } + + return _requestHeaders; + } + } + + protected override CancellationToken CancellationTokenCore => HttpContext.RequestAborted; + + protected override Metadata ResponseTrailersCore => throw new NotImplementedException(); + + protected override Status StatusCore { get; set; } + + protected override WriteOptions WriteOptionsCore + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + protected override AuthContext AuthContextCore + { + get + { + if (_authContext == null) + { + var clientCertificate = HttpContext.Connection.ClientCertificate; + + _authContext = clientCertificate == null + ? UnauthenticatedContext + : AuthContextHelpers.CreateAuthContext(clientCertificate); + } + + return _authContext; + } + } + + // TODO(JamesNK): Remove nullable override after Grpc.Core.Api update + protected override IDictionary UserStateCore => HttpContext.Items!; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) + { + throw new NotImplementedException(); + } + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) + { + // Headers can only be written once. Throw on subsequent call to write response header instead of silent no-op. + if (HttpContext.Response.HasStarted) + { + throw new InvalidOperationException("Response headers can only be sent once per call."); + } + + if (responseHeaders != null) + { + foreach (var entry in responseHeaders) + { + if (entry.IsBinary) + { + HttpContext.Response.Headers[entry.Key] = Convert.ToBase64String(entry.ValueBytes); + } + else + { + HttpContext.Response.Headers[entry.Key] = entry.Value; + } + } + } + + EnsureResponseHeaders(); + + return HttpContext.Response.BodyWriter.FlushAsync().GetAsTask(); + } + + internal void EnsureResponseHeaders() + { + if (!HttpContext.Response.HasStarted) + { + HttpContext.Response.StatusCode = StatusCodes.Status200OK; + HttpContext.Response.ContentType = MediaType.ReplaceEncoding("application/json", RequestEncoding); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs new file mode 100644 index 00000000000..893ab9dfd55 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Grpc.Core; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal sealed class HttpContextStreamWriter : IServerStreamWriter + where TResponse : class +{ + private readonly JsonTranscodingServerCallContext _context; + private readonly JsonSerializerOptions _serializerOptions; + private readonly object _writeLock; + private Task? _writeTask; + private bool _completed; + + public HttpContextStreamWriter(JsonTranscodingServerCallContext context, JsonSerializerOptions serializerOptions) + { + _context = context; + _serializerOptions = serializerOptions; + _writeLock = new object(); + } + + public WriteOptions WriteOptions + { + get => _context.WriteOptions; + set => _context.WriteOptions = value; + } + + public Task WriteAsync(TResponse message) + { + if (message == null) + { + return Task.FromException(new ArgumentNullException(nameof(message))); + } + + if (_completed || _context.CancellationToken.IsCancellationRequested) + { + return Task.FromException(new InvalidOperationException("Can't write the message because the request is complete.")); + } + + lock (_writeLock) + { + // Pending writes need to be awaited first + if (IsWriteInProgressUnsynchronized) + { + return Task.FromException(new InvalidOperationException("Can't write the message because the previous write is in progress.")); + } + + // Save write task to track whether it is complete. Must be set inside lock. + _writeTask = WriteMessageAndDelimiter(message); + } + + return _writeTask; + } + + private async Task WriteMessageAndDelimiter(TResponse message) + { + await JsonRequestHelpers.SendMessage(_context, _serializerOptions, message); + await _context.HttpContext.Response.Body.WriteAsync(GrpcProtocolConstants.StreamingDelimiter); + } + + public void Complete() + { + _completed = true; + } + + /// + /// A value indicating whether there is an async write already in progress. + /// Should only check this property when holding the write lock. + /// + private bool IsWriteInProgressUnsynchronized + { + get + { + var writeTask = _writeTask; + return writeTask != null && !writeTask.IsCompleted; + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/AnyConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/AnyConverter.cs new file mode 100644 index 00000000000..3c870e8fc02 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/AnyConverter.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Shared; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class AnyConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + internal const string AnyTypeUrlField = "@type"; + internal const string AnyWellKnownTypeValueField = "value"; + + public AnyConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var d = JsonDocument.ParseValue(ref reader); + if (!d.RootElement.TryGetProperty(AnyTypeUrlField, out var urlField)) + { + throw new InvalidOperationException("Any message with no @type."); + } + + var typeUrl = urlField.GetString(); + var typeName = Any.GetTypeName(typeUrl); + + var descriptor = Context.TypeRegistry.Find(typeName); + if (descriptor == null) + { + throw new InvalidOperationException($"Type registry has no descriptor for type name '{typeName}'."); + } + + IMessage data; + if (ServiceDescriptorHelpers.IsWellKnownType(descriptor)) + { + if (!d.RootElement.TryGetProperty(AnyWellKnownTypeValueField, out var valueField)) + { + throw new InvalidOperationException($"Expected '{AnyWellKnownTypeValueField}' property for well-known type Any body."); + } + + data = (IMessage)JsonSerializer.Deserialize(valueField, descriptor.ClrType, options)!; + } + else + { + data = (IMessage)JsonSerializer.Deserialize(d.RootElement, descriptor.ClrType, options)!; + } + + var message = new TMessage(); + message.Descriptor.Fields[Any.TypeUrlFieldNumber].Accessor.SetValue(message, typeUrl); + message.Descriptor.Fields[Any.ValueFieldNumber].Accessor.SetValue(message, data.ToByteString()); + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + var typeUrl = (string)value.Descriptor.Fields[Any.TypeUrlFieldNumber].Accessor.GetValue(value); + var data = (ByteString)value.Descriptor.Fields[Any.ValueFieldNumber].Accessor.GetValue(value); + var typeName = Any.GetTypeName(typeUrl); + var descriptor = Context.TypeRegistry.Find(typeName); + if (descriptor == null) + { + throw new InvalidOperationException($"Type registry has no descriptor for type name '{typeName}'."); + } + var valueMessage = descriptor.Parser.ParseFrom(data); + writer.WriteStartObject(); + writer.WriteString(AnyTypeUrlField, typeUrl); + + if (ServiceDescriptorHelpers.IsWellKnownType(descriptor)) + { + writer.WritePropertyName(AnyWellKnownTypeValueField); + if (ServiceDescriptorHelpers.IsWrapperType(descriptor)) + { + var wrappedValue = valueMessage.Descriptor.Fields[JsonConverterHelper.WrapperValueFieldNumber].Accessor.GetValue(valueMessage); + JsonSerializer.Serialize(writer, wrappedValue, wrappedValue.GetType(), options); + } + else + { + JsonSerializer.Serialize(writer, valueMessage, valueMessage.GetType(), options); + } + } + else + { + MessageConverter.WriteMessageFields(writer, valueMessage, Context.Settings, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/BoolConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/BoolConverter.cs new file mode 100644 index 00000000000..d79c4728fe9 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/BoolConverter.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class BoolConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetBoolean(); + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + + public override void WriteAsPropertyName(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WritePropertyName(value ? "true" : "false"); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ByteStringConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ByteStringConverter.cs new file mode 100644 index 00000000000..6e13385d9e8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ByteStringConverter.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class ByteStringConverter : JsonConverter +{ + public override ByteString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // TODO - handle base64 strings without padding + return UnsafeByteOperations.UnsafeWrap(reader.GetBytesFromBase64()); + } + + public override void Write(Utf8JsonWriter writer, ByteString value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToBase64()); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/DurationConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/DurationConverter.cs new file mode 100644 index 00000000000..aa9e23f5b2e --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/DurationConverter.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class DurationConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public DurationConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new InvalidOperationException("Expected string value for Duration."); + } + + var (seconds, nanos) = Legacy.ParseDuration(reader.GetString()!); + + var message = new TMessage(); + if (message is Duration duration) + { + duration.Seconds = seconds; + duration.Nanos = nanos; + } + else + { + message.Descriptor.Fields[Duration.SecondsFieldNumber].Accessor.SetValue(message, seconds); + message.Descriptor.Fields[Duration.NanosFieldNumber].Accessor.SetValue(message, nanos); + } + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + int nanos; + long seconds; + if (value is Duration duration) + { + nanos = duration.Nanos; + seconds = duration.Seconds; + } + else + { + nanos = (int)value.Descriptor.Fields[Duration.NanosFieldNumber].Accessor.GetValue(value); + seconds = (long)value.Descriptor.Fields[Duration.SecondsFieldNumber].Accessor.GetValue(value); + } + + var text = Legacy.GetDurationText(nanos, seconds); + writer.WriteStringValue(text); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs new file mode 100644 index 00000000000..5e2d70b65e4 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Google.Protobuf.Reflection; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class EnumConverter : SettingsConverterBase where TEnum : Enum +{ + public EnumConverter(JsonContext context) : base(context) + { + } + + public override TEnum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + var enumDescriptor = ResolveEnumDescriptor(typeToConvert); + if (enumDescriptor == null) + { + throw new InvalidOperationException($"Unable to resolve descriptor for {typeToConvert}."); + } + var valueDescriptor = enumDescriptor.FindValueByName(reader.GetString()!); + + return ConvertFromInteger(valueDescriptor.Number); + case JsonTokenType.Number: + return ConvertFromInteger(reader.GetInt32()); + case JsonTokenType.Null: + return default; + default: + throw new InvalidOperationException($"Unexpected JSON token: {reader.TokenType}."); + } + } + + private static EnumDescriptor? ResolveEnumDescriptor(Type typeToConvert) + { + var containingType = typeToConvert?.DeclaringType?.DeclaringType; + + if (containingType != null) + { + var messageDescriptor = JsonConverterHelper.GetMessageDescriptor(containingType); + if (messageDescriptor != null) + { + for (var i = 0; i < messageDescriptor.EnumTypes.Count; i++) + { + if (messageDescriptor.EnumTypes[i].ClrType == typeToConvert) + { + return messageDescriptor.EnumTypes[i]; + } + } + } + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + if (Context.Settings.WriteEnumsAsIntegers) + { + writer.WriteNumberValue(ConvertToInteger(value)); + } + else + { + var name = Legacy.OriginalEnumValueHelper.GetOriginalName(value); + if (name != null) + { + writer.WriteStringValue(name); + } + else + { + writer.WriteNumberValue(ConvertToInteger(value)); + } + } + } + + private static TEnum ConvertFromInteger(int integer) + { + if (!TryConvertToEnum(integer, out var value)) + { + throw new InvalidOperationException($"Integer can't be converted to enum {typeof(TEnum).FullName}."); + } + + return value; + } + + private static int ConvertToInteger(TEnum value) + { + if (!TryConvertToInteger(value, out var integer)) + { + throw new InvalidOperationException($"Enum {typeof(TEnum).FullName} can't be converted to integer."); + } + + return integer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryConvertToInteger(TEnum value, out int integer) + { + if (Unsafe.SizeOf() == Unsafe.SizeOf()) + { + integer = Unsafe.As(ref value); + return true; + } + integer = default; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryConvertToEnum(int integer, [NotNullWhen(true)] out TEnum? value) + { + if (Unsafe.SizeOf() == Unsafe.SizeOf()) + { + value = Unsafe.As(ref integer); + return true; + } + value = default; + return false; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/FieldMaskConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/FieldMaskConverter.cs new file mode 100644 index 00000000000..27a072959aa --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/FieldMaskConverter.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Linq; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class FieldMaskConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public FieldMaskConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var message = new TMessage(); + + if (reader.TokenType != JsonTokenType.String) + { + throw new InvalidOperationException("Expected string value for FieldMask."); + } + // TODO: Do we *want* to remove empty entries? Probably okay to treat "" as "no paths", but "foo,,bar"? + // Note: This logic replicates Google.Protobuf. Should follow their lead. + var jsonPaths = reader.GetString()!.Split(',', StringSplitOptions.RemoveEmptyEntries); + var messagePaths = (IList)message.Descriptor.Fields[FieldMask.PathsFieldNumber].Accessor.GetValue(message); + foreach (var path in jsonPaths) + { + messagePaths.Add(Legacy.ToSnakeCase(path)); + } + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + // Note: This logic replicates Google.Protobuf. Should follow their lead. + var paths = (IList)value.Descriptor.Fields[FieldMask.PathsFieldNumber].Accessor.GetValue(value); + var firstInvalid = paths.FirstOrDefault(p => !Legacy.IsPathValid(p)); + if (firstInvalid == null) + { + writer.WriteStringValue(string.Join(",", paths.Select(Legacy.ToJsonName))); + } + else + { + throw new InvalidOperationException($"Invalid field mask to be converted to JSON: {firstInvalid}."); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Int64Converter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Int64Converter.cs new file mode 100644 index 00000000000..7f78d9195d0 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Int64Converter.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class Int64Converter : SettingsConverterBase +{ + public Int64Converter(JsonContext context) : base(context) + { + } + + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return long.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + } + + return reader.GetInt64(); + } + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + if (!Context.Settings.WriteInt64sAsStrings) + { + writer.WriteNumberValue(value); + } + else + { + writer.WriteStringValue(value.ToString("d", CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonContext.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonContext.cs new file mode 100644 index 00000000000..f92838516fc --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonContext.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Protobuf.Reflection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class JsonContext +{ + public JsonContext(GrpcJsonSettings settings, TypeRegistry typeRegistry) + { + Settings = settings; + TypeRegistry = typeRegistry; + } + + public GrpcJsonSettings Settings { get; } + public TypeRegistry TypeRegistry { get; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForEnum.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForEnum.cs new file mode 100644 index 00000000000..1148628bdc9 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForEnum.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal class JsonConverterFactoryForEnum : JsonConverterFactory +{ + private readonly JsonContext _context; + + public JsonConverterFactoryForEnum(JsonContext context) + { + _context = context; + } + + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsEnum; + } + + public override JsonConverter CreateConverter( + Type typeToConvert, JsonSerializerOptions options) + { + var converter = (JsonConverter)Activator.CreateInstance( + typeof(EnumConverter<>).MakeGenericType(new Type[] { typeToConvert }), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: new object[] { _context }, + culture: null)!; + + return converter; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForMessage.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForMessage.cs new file mode 100644 index 00000000000..226e0e7ff4e --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForMessage.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal class JsonConverterFactoryForMessage : JsonConverterFactory +{ + private readonly JsonContext _context; + + public JsonConverterFactoryForMessage(JsonContext context) + { + _context = context; + } + + public override bool CanConvert(Type typeToConvert) + { + return typeof(IMessage).IsAssignableFrom(typeToConvert); + } + + public override JsonConverter CreateConverter( + Type typeToConvert, JsonSerializerOptions options) + { + JsonConverter converter = (JsonConverter)Activator.CreateInstance( + typeof(MessageConverter<>).MakeGenericType(new Type[] { typeToConvert }), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: new object[] { _context }, + culture: null)!; + + return converter; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWellKnownTypes.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWellKnownTypes.cs new file mode 100644 index 00000000000..c86dfb75d71 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWellKnownTypes.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal class JsonConverterFactoryForWellKnownTypes : JsonConverterFactory +{ + private readonly JsonContext _context; + + public JsonConverterFactoryForWellKnownTypes(JsonContext context) + { + _context = context; + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeof(IMessage).IsAssignableFrom(typeToConvert)) + { + return false; + } + + var descriptor = JsonConverterHelper.GetMessageDescriptor(typeToConvert); + if (descriptor == null) + { + return false; + } + + return WellKnownTypeNames.ContainsKey(descriptor.FullName); + } + + public override JsonConverter CreateConverter( + Type typeToConvert, JsonSerializerOptions options) + { + var descriptor = JsonConverterHelper.GetMessageDescriptor(typeToConvert)!; + var converterType = WellKnownTypeNames[descriptor.FullName]; + + var converter = (JsonConverter)Activator.CreateInstance( + converterType.MakeGenericType(new Type[] { typeToConvert }), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: new object[] { _context }, + culture: null)!; + + return converter; + } + + private static readonly Dictionary WellKnownTypeNames = new Dictionary + { + [Any.Descriptor.FullName] = typeof(AnyConverter<>), + [Duration.Descriptor.FullName] = typeof(DurationConverter<>), + [Timestamp.Descriptor.FullName] = typeof(TimestampConverter<>), + [FieldMask.Descriptor.FullName] = typeof(FieldMaskConverter<>), + [Struct.Descriptor.FullName] = typeof(StructConverter<>), + [ListValue.Descriptor.FullName] = typeof(ListValueConverter<>), + [Value.Descriptor.FullName] = typeof(ValueConverter<>), + }; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWrappers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWrappers.cs new file mode 100644 index 00000000000..0147d4c2729 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterFactoryForWrappers.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Grpc.Shared; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal class JsonConverterFactoryForWrappers : JsonConverterFactory +{ + private readonly JsonContext _context; + + public JsonConverterFactoryForWrappers(JsonContext context) + { + _context = context; + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeof(IMessage).IsAssignableFrom(typeToConvert)) + { + return false; + } + + var descriptor = JsonConverterHelper.GetMessageDescriptor(typeToConvert); + if (descriptor == null) + { + return false; + } + + return ServiceDescriptorHelpers.IsWrapperType(descriptor); + } + + public override JsonConverter CreateConverter( + Type typeToConvert, JsonSerializerOptions options) + { + var converter = (JsonConverter)Activator.CreateInstance( + typeof(WrapperConverter<>).MakeGenericType(new Type[] { typeToConvert }), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: new object[] { _context }, + culture: null)!; + + return converter; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterHelper.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterHelper.cs new file mode 100644 index 00000000000..385dd248800 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/JsonConverterHelper.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Reflection; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Grpc.Shared; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal static class JsonConverterHelper +{ + internal const int WrapperValueFieldNumber = Int32Value.ValueFieldNumber; + + internal static JsonSerializerOptions CreateSerializerOptions(JsonContext context, bool isStreamingOptions = false) + { + // Streaming is line delimited between messages. That means JSON can't be indented as it adds new lines. + // For streaming to work, indenting must be disabled when streaming. + var writeIndented = !isStreamingOptions ? context.Settings.WriteIndented : false; + + var options = new JsonSerializerOptions + { + WriteIndented = writeIndented, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + options.Converters.Add(new NullValueConverter()); + options.Converters.Add(new ByteStringConverter()); + options.Converters.Add(new Int64Converter(context)); + options.Converters.Add(new UInt64Converter(context)); + options.Converters.Add(new BoolConverter()); + options.Converters.Add(new JsonConverterFactoryForEnum(context)); + options.Converters.Add(new JsonConverterFactoryForWrappers(context)); + options.Converters.Add(new JsonConverterFactoryForWellKnownTypes(context)); + options.Converters.Add(new JsonConverterFactoryForMessage(context)); + + return options; + } + + internal static Type GetFieldType(FieldDescriptor descriptor) + { + switch (descriptor.FieldType) + { + case FieldType.Bool: + return typeof(bool); + case FieldType.Bytes: + return typeof(ByteString); + case FieldType.String: + return typeof(string); + case FieldType.Double: + return typeof(double); + case FieldType.SInt32: + case FieldType.Int32: + case FieldType.SFixed32: + return typeof(int); + case FieldType.Enum: + return descriptor.EnumType.ClrType; + case FieldType.Fixed32: + case FieldType.UInt32: + return typeof(uint); + case FieldType.Fixed64: + case FieldType.UInt64: + return typeof(ulong); + case FieldType.SFixed64: + case FieldType.Int64: + case FieldType.SInt64: + return typeof(long); + case FieldType.Float: + return typeof(float); + case FieldType.Message: + case FieldType.Group: // Never expect to get this, but... + if (ServiceDescriptorHelpers.IsWrapperType(descriptor.MessageType)) + { + var t = GetFieldType(descriptor.MessageType.Fields[WrapperValueFieldNumber]); + if (t.IsValueType) + { + return typeof(Nullable<>).MakeGenericType(t); + } + + return t; + } + return descriptor.MessageType.ClrType; + default: + throw new ArgumentException("Invalid field type"); + } + } + + internal static MessageDescriptor? GetMessageDescriptor(Type typeToConvert) + { + var property = typeToConvert.GetProperty("Descriptor", BindingFlags.Static | BindingFlags.Public, binder: null, typeof(MessageDescriptor), Type.EmptyTypes, modifiers: null); + if (property == null) + { + return null; + } + + return property.GetValue(null, null) as MessageDescriptor; + } + + public static void PopulateMap(ref Utf8JsonReader reader, JsonSerializerOptions options, IMessage message, FieldDescriptor fieldDescriptor) + { + var mapFields = fieldDescriptor.MessageType.Fields.InFieldNumberOrder(); + var mapKey = mapFields[0]; + var mapValue = mapFields[1]; + + var keyType = GetFieldType(mapKey); + var valueType = GetFieldType(mapValue); + + var repeatedFieldType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); + var newValues = (IDictionary)JsonSerializer.Deserialize(ref reader, repeatedFieldType, options)!; + + var existingValue = (IDictionary)fieldDescriptor.Accessor.GetValue(message); + foreach (DictionaryEntry item in newValues) + { + existingValue[item.Key] = item.Value; + } + } + + public static void PopulateList(ref Utf8JsonReader reader, JsonSerializerOptions options, IMessage message, FieldDescriptor fieldDescriptor) + { + var fieldType = GetFieldType(fieldDescriptor); + var repeatedFieldType = typeof(List<>).MakeGenericType(fieldType); + var newValues = (IList)JsonSerializer.Deserialize(ref reader, repeatedFieldType, options)!; + + var existingValue = (IList)fieldDescriptor.Accessor.GetValue(message); + foreach (var item in newValues) + { + existingValue.Add(item); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Legacy.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Legacy.cs new file mode 100644 index 00000000000..bcc8db8c077 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/Legacy.cs @@ -0,0 +1,403 @@ +#pragma warning disable IDE0073 // The file header does not match the required text +#region Copyright notice and license +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +#endregion +#pragma warning restore IDE0073 // The file header does not match the required text + +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +// Source here is from https://github.com/protocolbuffers/protobuf +// Most of this code will be replaced over time with optimized implementations. +internal static class Legacy +{ + private static readonly Regex TimestampRegex = new Regex(@"^(?[0-9]{4}-[01][0-9]-[0-3][0-9]T[012][0-9]:[0-5][0-9]:[0-5][0-9])(?\.[0-9]{1,9})?(?(Z|[+-][0-1][0-9]:[0-5][0-9]))$", RegexOptions.Compiled); + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // Constants determined programmatically, but then hard-coded so they can be constant expressions. + private const long BclSecondsAtUnixEpoch = 62135596800; + internal const long UnixSecondsAtBclMaxValue = 253402300799; + internal const long UnixSecondsAtBclMinValue = -BclSecondsAtUnixEpoch; + internal const int MaxNanos = Duration.NanosecondsPerSecond - 1; + private static readonly int[] SubsecondScalingFactors = { 0, 100000000, 100000000, 10000000, 1000000, 100000, 10000, 1000, 100, 10, 1 }; + + private static readonly Regex DurationRegex = new Regex(@"^(?-)?(?[0-9]{1,12})(?\.[0-9]{1,9})?s$", RegexOptions.Compiled); + + public static (long seconds, int nanos) ParseTimestamp(string value) + { + var match = TimestampRegex.Match(value); + if (!match.Success) + { + throw new InvalidOperationException($"Invalid Timestamp value: {value}"); + } + var dateTime = match.Groups["datetime"].Value; + var subseconds = match.Groups["subseconds"].Value; + var offset = match.Groups["offset"].Value; + + try + { + DateTime parsed = DateTime.ParseExact( + dateTime, + "yyyy-MM-dd'T'HH:mm:ss", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + // TODO: It would be nice not to have to create all these objects... easy to optimize later though. + Timestamp timestamp = Timestamp.FromDateTime(parsed); + int nanosToAdd = 0; + if (subseconds != "") + { + // This should always work, as we've got 1-9 digits. + int parsedFraction = int.Parse(subseconds.AsSpan(1), CultureInfo.InvariantCulture); + nanosToAdd = parsedFraction * SubsecondScalingFactors[subseconds.Length]; + } + int secondsToAdd = 0; + if (offset != "Z") + { + // This is the amount we need to *subtract* from the local time to get to UTC - hence - => +1 and vice versa. + int sign = offset[0] == '-' ? 1 : -1; + int hours = int.Parse(offset.AsSpan(1, 2), CultureInfo.InvariantCulture); + int minutes = int.Parse(offset.AsSpan(4, 2), CultureInfo.InvariantCulture); + int totalMinutes = hours * 60 + minutes; + if (totalMinutes > 18 * 60) + { + throw new InvalidOperationException($"Invalid Timestamp value: {value}"); + } + if (totalMinutes == 0 && sign == 1) + { + // This is an offset of -00:00, which means "unknown local offset". It makes no sense for a timestamp. + throw new InvalidOperationException($"Invalid Timestamp value: {value}"); + } + // We need to *subtract* the offset from local time to get UTC. + secondsToAdd = sign * totalMinutes * 60; + } + // Ensure we've got the right signs. Currently unnecessary, but easy to do. + if (secondsToAdd < 0 && nanosToAdd > 0) + { + secondsToAdd++; + nanosToAdd = nanosToAdd - Duration.NanosecondsPerSecond; + } + if (secondsToAdd != 0 || nanosToAdd != 0) + { + timestamp += new Duration { Nanos = nanosToAdd, Seconds = secondsToAdd }; + // The resulting timestamp after offset change would be out of our expected range. Currently the Timestamp message doesn't validate this + // anywhere, but we shouldn't parse it. + if (timestamp.Seconds < UnixSecondsAtBclMinValue || timestamp.Seconds > UnixSecondsAtBclMaxValue) + { + throw new InvalidOperationException($"Invalid Timestamp value: {value}"); + } + } + + return (timestamp.Seconds, timestamp.Nanos); + } + catch (FormatException) + { + throw new InvalidOperationException($"Invalid Timestamp value: {value}"); + } + } + + private static bool IsNormalized(long seconds, int nanoseconds) => + nanoseconds >= 0 && + nanoseconds <= MaxNanos && + seconds >= UnixSecondsAtBclMinValue && + seconds <= UnixSecondsAtBclMaxValue; + + public static string GetTimestampText(int nanos, long seconds) + { + if (IsNormalized(seconds, nanos)) + { + // Use .NET's formatting for the value down to the second, including an opening double quote (as it's a string value) + DateTime dateTime = UnixEpoch.AddSeconds(seconds); + var builder = new StringBuilder(); + builder.Append(dateTime.ToString("yyyy'-'MM'-'dd'T'HH:mm:ss", CultureInfo.InvariantCulture)); + + if (nanos != 0) + { + builder.Append('.'); + // Output to 3, 6 or 9 digits. + if (nanos % 1000000 == 0) + { + builder.Append((nanos / 1000000).ToString("d3", CultureInfo.InvariantCulture)); + } + else if (nanos % 1000 == 0) + { + builder.Append((nanos / 1000).ToString("d6", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(nanos.ToString("d9", CultureInfo.InvariantCulture)); + } + } + + builder.Append('Z'); + + return builder.ToString(); + } + else + { + throw new InvalidOperationException("Non-normalized timestamp value."); + } + } + + public static (long seconds, int nanos) ParseDuration(string value) + { + var match = DurationRegex.Match(value); + if (!match.Success) + { + throw new InvalidOperationException("Invalid Duration value: " + value); + } + var sign = match.Groups["sign"].Value; + var secondsText = match.Groups["int"].Value; + // Prohibit leading insignificant zeroes + if (secondsText[0] == '0' && secondsText.Length > 1) + { + throw new InvalidOperationException("Invalid Duration value: " + value); + } + var subseconds = match.Groups["subseconds"].Value; + var multiplier = sign == "-" ? -1 : 1; + + try + { + long seconds = long.Parse(secondsText, CultureInfo.InvariantCulture) * multiplier; + int nanos = 0; + if (subseconds != "") + { + // This should always work, as we've got 1-9 digits. + int parsedFraction = int.Parse(subseconds.AsSpan(1), CultureInfo.InvariantCulture); + nanos = parsedFraction * SubsecondScalingFactors[subseconds.Length] * multiplier; + } + if (!IsNormalized(seconds, nanos)) + { + throw new InvalidOperationException("Invalid Duration value: " + value); + } + + return (seconds, nanos); + } + catch (FormatException) + { + throw new InvalidOperationException("Invalid Duration value: " + value); + } + } + + public static string GetDurationText(int nanos, long seconds) + { + if (IsNormalized(seconds, nanos)) + { + var builder = new StringBuilder(); + // The seconds part will normally provide the minus sign if we need it, but not if it's 0... + if (seconds == 0 && nanos < 0) + { + builder.Append('-'); + } + + builder.Append(seconds.ToString("d", CultureInfo.InvariantCulture)); + AppendNanoseconds(builder, Math.Abs(nanos)); + builder.Append('s'); + + return builder.ToString(); + } + else + { + throw new InvalidOperationException("Non-normalized duration value."); + } + } + + /// + /// Appends a number of nanoseconds to a StringBuilder. Either 0 digits are added (in which + /// case no "." is appended), or 3 6 or 9 digits. This is internal for use in Timestamp as well + /// as Duration. + /// + internal static void AppendNanoseconds(StringBuilder builder, int nanos) + { + if (nanos != 0) + { + builder.Append('.'); + // Output to 3, 6 or 9 digits. + if (nanos % 1000000 == 0) + { + builder.Append((nanos / 1000000).ToString("d3", CultureInfo.InvariantCulture)); + } + else if (nanos % 1000 == 0) + { + builder.Append((nanos / 1000).ToString("d6", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(nanos.ToString("d9", CultureInfo.InvariantCulture)); + } + } + } + + // Ported from src/google/protobuf/util/internal/utility.cc + internal static string ToSnakeCase(string text) + { + var builder = new StringBuilder(text.Length * 2); + // Note: this is probably unnecessary now, but currently retained to be as close as possible to the + // C++, whilst still throwing an exception on underscores. + bool wasNotUnderscore = false; // Initialize to false for case 1 (below) + bool wasNotCap = false; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c >= 'A' && c <= 'Z') // ascii_isupper + { + // Consider when the current character B is capitalized: + // 1) At beginning of input: "B..." => "b..." + // (e.g. "Biscuit" => "biscuit") + // 2) Following a lowercase: "...aB..." => "...a_b..." + // (e.g. "gBike" => "g_bike") + // 3) At the end of input: "...AB" => "...ab" + // (e.g. "GoogleLAB" => "google_lab") + // 4) Followed by a lowercase: "...ABc..." => "...a_bc..." + // (e.g. "GBike" => "g_bike") + if (wasNotUnderscore && // case 1 out + (wasNotCap || // case 2 in, case 3 out + (i + 1 < text.Length && // case 3 out + (text[i + 1] >= 'a' && text[i + 1] <= 'z')))) // ascii_islower(text[i + 1]) + { // case 4 in + // We add an underscore for case 2 and case 4. + builder.Append('_'); + } + // ascii_tolower, but we already know that c *is* an upper case ASCII character... + builder.Append((char)(c + 'a' - 'A')); + wasNotUnderscore = true; + wasNotCap = false; + } + else + { + builder.Append(c); + if (c == '_') + { + throw new InvalidOperationException($"Invalid field mask: {text}"); + } + wasNotUnderscore = true; + wasNotCap = true; + } + } + return builder.ToString(); + } + + internal static string ToJsonName(string name) + { + var result = new StringBuilder(name.Length); + var isNextUpperCase = false; + foreach (var ch in name) + { + if (ch == '_') + { + isNextUpperCase = true; + } + else if (isNextUpperCase) + { + result.Append(char.ToUpperInvariant(ch)); + isNextUpperCase = false; + } + else + { + result.Append(ch); + } + } + return result.ToString(); + } + + /// + /// Checks whether the given path is valid for a field mask. + /// + /// true if the path is valid; false otherwise + internal static bool IsPathValid(string input) + { + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (c >= 'A' && c <= 'Z') + { + return false; + } + if (c == '_' && i < input.Length - 1) + { + var next = input[i + 1]; + if (next < 'a' || next > 'z') + { + return false; + } + } + } + return true; + } + + // Effectively a cache of mapping from enum values to the original name as specified in the proto file, + // fetched by reflection. + // The need for this is unfortunate, as is its unbounded size, but realistically it shouldn't cause issues. + internal static class OriginalEnumValueHelper + { + private static readonly ConcurrentDictionary> _dictionaries + = new ConcurrentDictionary>(); + + internal static string? GetOriginalName(object value) + { + var enumType = value.GetType(); + Dictionary? nameMapping; + lock (_dictionaries) + { + if (!_dictionaries.TryGetValue(enumType, out nameMapping)) + { + nameMapping = GetNameMapping(enumType); + _dictionaries[enumType] = nameMapping; + } + } + + // If this returns false, originalName will be null, which is what we want. + nameMapping.TryGetValue(value, out var originalName); + return originalName; + } + + private static Dictionary GetNameMapping(Type enumType) + { + return enumType.GetTypeInfo().DeclaredFields + .Where(f => f.IsStatic) + .Where(f => f.GetCustomAttributes() + .FirstOrDefault()?.PreferredAlias ?? true) + .ToDictionary(f => f.GetValue(null)!, + f => f.GetCustomAttributes() + .FirstOrDefault() + // If the attribute hasn't been applied, fall back to the name of the field. + ?.Name ?? f.Name); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ListValueConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ListValueConverter.cs new file mode 100644 index 00000000000..57af2479ece --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ListValueConverter.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class ListValueConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public ListValueConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var message = new TMessage(); + JsonConverterHelper.PopulateList(ref reader, options, message, message.Descriptor.Fields[ListValue.ValuesFieldNumber]); + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + var list = (IList)value.Descriptor.Fields[ListValue.ValuesFieldNumber].Accessor.GetValue(value); + + JsonSerializer.Serialize(writer, list, list.GetType(), options); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs new file mode 100644 index 00000000000..ec51deb662b --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs @@ -0,0 +1,187 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +// This converter should be temporary until System.Text.Json supports overriding contacts. +// We want to eliminate this converter because System.Text.Json has to buffer content in converters. +internal sealed class MessageConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + private readonly Dictionary _jsonFieldMap; + + public MessageConverter(JsonContext context) : base(context) + { + _jsonFieldMap = CreateJsonFieldMap((new TMessage()).Descriptor.Fields.InFieldNumberOrder()); + } + + public override TMessage Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var message = new TMessage(); + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new InvalidOperationException($"Unexpected JSON token: {reader.TokenType}"); + } + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + return message; + case JsonTokenType.PropertyName: + if (_jsonFieldMap.TryGetValue(reader.GetString()!, out var fieldDescriptor)) + { + if (fieldDescriptor.ContainingOneof != null) + { + if (fieldDescriptor.ContainingOneof.Accessor.GetCaseFieldDescriptor(message) != null) + { + throw new InvalidOperationException($"Multiple values specified for oneof {fieldDescriptor.ContainingOneof.Name}."); + } + } + + if (fieldDescriptor.IsMap) + { + JsonConverterHelper.PopulateMap(ref reader, options, message, fieldDescriptor); + } + else if (fieldDescriptor.IsRepeated) + { + JsonConverterHelper.PopulateList(ref reader, options, message, fieldDescriptor); + } + else + { + var fieldType = JsonConverterHelper.GetFieldType(fieldDescriptor); + var propertyValue = JsonSerializer.Deserialize(ref reader, fieldType, options); + fieldDescriptor.Accessor.SetValue(message, propertyValue); + } + } + else + { + reader.Skip(); + } + break; + case JsonTokenType.Comment: + // Ignore + break; + default: + throw new InvalidOperationException($"Unexpected JSON token: {reader.TokenType}"); + } + } + + throw new Exception(); + } + + public override void Write( + Utf8JsonWriter writer, + TMessage value, + JsonSerializerOptions options) + { + WriteMessage(writer, value, options); + } + + private void WriteMessage(Utf8JsonWriter writer, IMessage message, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + WriteMessageFields(writer, message, Context.Settings, options); + + writer.WriteEndObject(); + } + + internal static void WriteMessageFields(Utf8JsonWriter writer, IMessage message, GrpcJsonSettings settings, JsonSerializerOptions options) + { + var fields = message.Descriptor.Fields; + + foreach (var field in fields.InFieldNumberOrder()) + { + var accessor = field.Accessor; + var value = accessor.GetValue(message); + if (!ShouldFormatFieldValue(message, field, value, !settings.IgnoreDefaultValues)) + { + continue; + } + + writer.WritePropertyName(accessor.Descriptor.JsonName); + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + } + + private static Dictionary CreateJsonFieldMap(IList fields) + { + var map = new Dictionary(); + foreach (var field in fields) + { + map[field.Name] = field; + map[field.JsonName] = field; + } + return new Dictionary(map); + } + + /// + /// Determines whether or not a field value should be serialized according to the field, + /// its value in the message, and the settings of this formatter. + /// + private static bool ShouldFormatFieldValue(IMessage message, FieldDescriptor field, object value, bool formatDefaultValues) => + field.HasPresence + // Fields that support presence *just* use that + ? field.Accessor.HasValue(message) + // Otherwise, format if either we've been asked to format default values, or if it's + // not a default value anyway. + : formatDefaultValues || !IsDefaultValue(field, value); + + private static bool IsDefaultValue(FieldDescriptor descriptor, object value) + { + if (descriptor.IsMap) + { + IDictionary dictionary = (IDictionary)value; + return dictionary.Count == 0; + } + if (descriptor.IsRepeated) + { + IList list = (IList)value; + return list.Count == 0; + } + switch (descriptor.FieldType) + { + case FieldType.Bool: + return (bool)value == false; + case FieldType.Bytes: + return (ByteString)value == ByteString.Empty; + case FieldType.String: + return (string)value == ""; + case FieldType.Double: + return (double)value == 0.0; + case FieldType.SInt32: + case FieldType.Int32: + case FieldType.SFixed32: + case FieldType.Enum: + return (int)value == 0; + case FieldType.Fixed32: + case FieldType.UInt32: + return (uint)value == 0; + case FieldType.Fixed64: + case FieldType.UInt64: + return (ulong)value == 0; + case FieldType.SFixed64: + case FieldType.Int64: + case FieldType.SInt64: + return (long)value == 0; + case FieldType.Float: + return (float)value == 0f; + case FieldType.Message: + case FieldType.Group: // Never expect to get this, but... + return value == null; + default: + throw new ArgumentException("Invalid field type"); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/NullValueConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/NullValueConverter.cs new file mode 100644 index 00000000000..1db9841ace2 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/NullValueConverter.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class NullValueConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override NullValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + if (reader.GetString() == "NULL_VALUE") + { + return NullValue.NullValue; + } + else + { + throw new InvalidOperationException($"Invalid enum value: {reader.GetString()} for enum type: google.protobuf.NullValue"); + } + case JsonTokenType.Number: + return (NullValue)reader.GetInt32(); + case JsonTokenType.Null: + return NullValue.NullValue; + default: + throw new InvalidOperationException($"Unexpected JSON token: {reader.TokenType}"); + } + } + + public override void Write(Utf8JsonWriter writer, NullValue value, JsonSerializerOptions options) + { + writer.WriteNullValue(); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/SettingsConverterBase.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/SettingsConverterBase.cs new file mode 100644 index 00000000000..c3f0fae4ad3 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/SettingsConverterBase.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal abstract class SettingsConverterBase : JsonConverter +{ + public SettingsConverterBase(JsonContext context) + { + Context = context; + } + + public JsonContext Context { get; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/StructConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/StructConverter.cs new file mode 100644 index 00000000000..cec88dcf27f --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/StructConverter.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class StructConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public StructConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var message = new TMessage(); + JsonConverterHelper.PopulateMap(ref reader, options, message, message.Descriptor.Fields[Struct.FieldsFieldNumber]); + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + var fields = (IDictionary)value.Descriptor.Fields[Struct.FieldsFieldNumber].Accessor.GetValue(value); + foreach (DictionaryEntry entry in fields) + { + var k = (string)entry.Key; + var v = (IMessage?)entry.Value; + if (string.IsNullOrEmpty(k) || v == null) + { + throw new InvalidOperationException("Struct fields cannot have an empty key or a null value."); + } + + writer.WritePropertyName(k); + JsonSerializer.Serialize(writer, v, v.GetType(), options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/TimestampConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/TimestampConverter.cs new file mode 100644 index 00000000000..00c16bc231b --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/TimestampConverter.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class TimestampConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public TimestampConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new InvalidOperationException("Expected string value for Timestamp."); + } + var (seconds, nanos) = Legacy.ParseTimestamp(reader.GetString()!); + + var message = new TMessage(); + if (message is Timestamp timestamp) + { + timestamp.Seconds = seconds; + timestamp.Nanos = nanos; + } + else + { + message.Descriptor.Fields[Timestamp.SecondsFieldNumber].Accessor.SetValue(message, seconds); + message.Descriptor.Fields[Timestamp.NanosFieldNumber].Accessor.SetValue(message, nanos); + } + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + int nanos; + long seconds; + if (value is Timestamp timestamp) + { + nanos = timestamp.Nanos; + seconds = timestamp.Seconds; + } + else + { + nanos = (int)value.Descriptor.Fields[Timestamp.NanosFieldNumber].Accessor.GetValue(value); + seconds = (long)value.Descriptor.Fields[Timestamp.SecondsFieldNumber].Accessor.GetValue(value); + } + + var text = Legacy.GetTimestampText(nanos, seconds); + writer.WriteStringValue(text); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/UInt64Converter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/UInt64Converter.cs new file mode 100644 index 00000000000..525fd4e8cd7 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/UInt64Converter.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class UInt64Converter : SettingsConverterBase +{ + public UInt64Converter(JsonContext context) : base(context) + { + } + + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return ulong.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + } + + return reader.GetUInt64(); + } + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) + { + if (!Context.Settings.WriteInt64sAsStrings) + { + writer.WriteNumberValue(value); + } + else + { + writer.WriteStringValue(value.ToString("d", CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ValueConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ValueConverter.cs new file mode 100644 index 00000000000..6d740ec300c --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/ValueConverter.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class ValueConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public ValueConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var message = new TMessage(); + var fields = message.Descriptor.Fields; + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + { + var field = fields[Value.StructValueFieldNumber]; + var structMessage = JsonSerializer.Deserialize(ref reader, field.MessageType.ClrType, options); + field.Accessor.SetValue(message, structMessage); + break; + } + case JsonTokenType.StartArray: + { + var field = fields[Value.ListValueFieldNumber]; + var list = JsonSerializer.Deserialize(ref reader, field.MessageType.ClrType, options); + field.Accessor.SetValue(message, list); + break; + } + case JsonTokenType.Comment: + break; + case JsonTokenType.String: + fields[Value.StringValueFieldNumber].Accessor.SetValue(message, reader.GetString()!); + break; + case JsonTokenType.Number: + fields[Value.NumberValueFieldNumber].Accessor.SetValue(message, reader.GetDouble()); + break; + case JsonTokenType.True: + fields[Value.BoolValueFieldNumber].Accessor.SetValue(message, true); + break; + case JsonTokenType.False: + fields[Value.BoolValueFieldNumber].Accessor.SetValue(message, false); + break; + case JsonTokenType.Null: + fields[Value.NullValueFieldNumber].Accessor.SetValue(message, 0); + break; + default: + throw new InvalidOperationException("Unexpected token type: " + reader.TokenType); + } + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + var specifiedField = value.Descriptor.Oneofs[0].Accessor.GetCaseFieldDescriptor(value); + if (specifiedField == null) + { + throw new InvalidOperationException("Value message must contain a value for the oneof."); + } + + object v = specifiedField.Accessor.GetValue(value); + + switch (specifiedField.FieldNumber) + { + case Value.BoolValueFieldNumber: + case Value.StringValueFieldNumber: + case Value.NumberValueFieldNumber: + case Value.StructValueFieldNumber: + case Value.ListValueFieldNumber: + JsonSerializer.Serialize(writer, v, v.GetType(), options); + break; + case Value.NullValueFieldNumber: + writer.WriteNullValue(); + break; + default: + throw new InvalidOperationException("Unexpected case in struct field: " + specifiedField.FieldNumber); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/WrapperConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/WrapperConverter.cs new file mode 100644 index 00000000000..1cb92451857 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/WrapperConverter.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; + +internal sealed class WrapperConverter : SettingsConverterBase where TMessage : IMessage, new() +{ + public WrapperConverter(JsonContext context) : base(context) + { + } + + public override TMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var message = new TMessage(); + var valueDescriptor = message.Descriptor.Fields[JsonConverterHelper.WrapperValueFieldNumber]; + var t = JsonConverterHelper.GetFieldType(valueDescriptor); + var value = JsonSerializer.Deserialize(ref reader, t, options); + valueDescriptor.Accessor.SetValue(message, value); + + return message; + } + + public override void Write(Utf8JsonWriter writer, TMessage value, JsonSerializerOptions options) + { + var valueDescriptor = value.Descriptor.Fields[JsonConverterHelper.WrapperValueFieldNumber]; + var innerValue = valueDescriptor.Accessor.GetValue(value); + JsonSerializer.Serialize(writer, innerValue, innerValue.GetType(), options); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs new file mode 100644 index 00000000000..5666a856a3d --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs @@ -0,0 +1,346 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Linq; +using System.Text; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Grpc.Core; +using Grpc.Gateway.Runtime; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +internal static class JsonRequestHelpers +{ + public const string JsonContentType = "application/json"; + public const string JsonContentTypeWithCharset = "application/json; charset=utf-8"; + + public static bool HasJsonContentType(HttpRequest request, out StringSegment charset) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var mt)) + { + charset = default; + return false; + } + + // Matches application/json + if (mt.MediaType.Equals(JsonContentType, StringComparison.OrdinalIgnoreCase)) + { + charset = mt.Charset; + return true; + } + + // Matches +json, e.g. application/ld+json + if (mt.Suffix.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + charset = mt.Charset; + return true; + } + + charset = default; + return false; + } + + public static (Stream stream, bool usesTranscodingStream) GetStream(Stream innerStream, Encoding? encoding) + { + if (encoding == null || encoding.CodePage == Encoding.UTF8.CodePage) + { + return (innerStream, false); + } + + var stream = Encoding.CreateTranscodingStream(innerStream, encoding, Encoding.UTF8, leaveOpen: true); + return (stream, true); + } + + public static Encoding? GetEncodingFromCharset(StringSegment charset) + { + if (charset.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) + { + // This is an optimization for utf-8 that prevents the Substring caused by + // charset.Value + return Encoding.UTF8; + } + + try + { + // charset.Value might be an invalid encoding name as in charset=invalid. + return charset.HasValue ? Encoding.GetEncoding(charset.Value) : null; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unable to read the request as JSON because the request content type charset '{charset}' is not a known encoding.", ex); + } + } + + public static async Task SendErrorResponse(HttpResponse response, Encoding encoding, Status status, JsonSerializerOptions options) + { + if (!response.HasStarted) + { + response.StatusCode = MapStatusCodeToHttpStatus(status.StatusCode); + response.ContentType = MediaType.ReplaceEncoding("application/json", encoding); + } + + var e = new Error + { + Error_ = status.Detail, + Message = status.Detail, + Code = (int)status.StatusCode + }; + + await WriteResponseMessage(response, encoding, e, options); + } + + public static int MapStatusCodeToHttpStatus(StatusCode statusCode) + { + switch (statusCode) + { + case StatusCode.OK: + return StatusCodes.Status200OK; + case StatusCode.Cancelled: + return StatusCodes.Status408RequestTimeout; + case StatusCode.Unknown: + return StatusCodes.Status500InternalServerError; + case StatusCode.InvalidArgument: + return StatusCodes.Status400BadRequest; + case StatusCode.DeadlineExceeded: + return StatusCodes.Status504GatewayTimeout; + case StatusCode.NotFound: + return StatusCodes.Status404NotFound; + case StatusCode.AlreadyExists: + return StatusCodes.Status409Conflict; + case StatusCode.PermissionDenied: + return StatusCodes.Status403Forbidden; + case StatusCode.Unauthenticated: + return StatusCodes.Status401Unauthorized; + case StatusCode.ResourceExhausted: + return StatusCodes.Status429TooManyRequests; + case StatusCode.FailedPrecondition: + // Note, this deliberately doesn't translate to the similarly named '412 Precondition Failed' HTTP response status. + return StatusCodes.Status400BadRequest; + case StatusCode.Aborted: + return StatusCodes.Status409Conflict; + case StatusCode.OutOfRange: + return StatusCodes.Status400BadRequest; + case StatusCode.Unimplemented: + return StatusCodes.Status501NotImplemented; + case StatusCode.Internal: + return StatusCodes.Status500InternalServerError; + case StatusCode.Unavailable: + return StatusCodes.Status503ServiceUnavailable; + case StatusCode.DataLoss: + return StatusCodes.Status500InternalServerError; + } + + return StatusCodes.Status500InternalServerError; + } + + public static async Task WriteResponseMessage(HttpResponse response, Encoding encoding, object responseBody, JsonSerializerOptions options) + { + var (stream, usesTranscodingStream) = GetStream(response.Body, encoding); + + try + { + await JsonSerializer.SerializeAsync(stream, responseBody, options); + } + finally + { + if (usesTranscodingStream) + { + await stream.DisposeAsync(); + } + } + } + + public static async Task ReadMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions) where TRequest : class + { + try + { + GrpcServerLog.ReadingMessage(serverCallContext.Logger); + + IMessage requestMessage; + if (serverCallContext.DescriptorInfo.BodyDescriptor != null) + { + if (!serverCallContext.IsJsonRequestContent) + { + GrpcServerLog.UnsupportedRequestContentType(serverCallContext.Logger, serverCallContext.HttpContext.Request.ContentType); + throw new RpcException(new Status(StatusCode.InvalidArgument, "Request content-type of application/json is required.")); + } + + var (stream, usesTranscodingStream) = GetStream(serverCallContext.HttpContext.Request.Body, serverCallContext.RequestEncoding); + + try + { + if (serverCallContext.DescriptorInfo.BodyDescriptorRepeated) + { + requestMessage = (IMessage)Activator.CreateInstance(); + + // TODO: JsonSerializer currently doesn't support deserializing values onto an existing object or collection. + // Either update this to use new functionality in JsonSerializer or improve work-around perf. + var type = JsonConverterHelper.GetFieldType(serverCallContext.DescriptorInfo.BodyFieldDescriptors.Last()); + var listType = typeof(List<>).MakeGenericType(type); + + GrpcServerLog.DeserializingMessage(serverCallContext.Logger, listType); + var repeatedContent = (IList)(await JsonSerializer.DeserializeAsync(stream, listType, serializerOptions))!; + + ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, serverCallContext.DescriptorInfo.BodyFieldDescriptors, repeatedContent); + } + else + { + IMessage bodyContent; + + try + { + GrpcServerLog.DeserializingMessage(serverCallContext.Logger, serverCallContext.DescriptorInfo.BodyDescriptor.ClrType); + bodyContent = (IMessage)(await JsonSerializer.DeserializeAsync(stream, serverCallContext.DescriptorInfo.BodyDescriptor.ClrType, serializerOptions))!; + } + catch (JsonException) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Request JSON payload is not correctly formatted.")); + } + catch (Exception exception) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, exception.Message)); + } + + if (serverCallContext.DescriptorInfo.BodyFieldDescriptors != null) + { + requestMessage = (IMessage)Activator.CreateInstance(); + ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, serverCallContext.DescriptorInfo.BodyFieldDescriptors, bodyContent!); // TODO - check nullability + } + else + { + requestMessage = bodyContent; + } + } + } + finally + { + if (usesTranscodingStream) + { + await stream.DisposeAsync(); + } + } + } + else + { + requestMessage = (IMessage)Activator.CreateInstance(); + } + + foreach (var parameterDescriptor in serverCallContext.DescriptorInfo.RouteParameterDescriptors) + { + var routeValue = serverCallContext.HttpContext.Request.RouteValues[parameterDescriptor.Key]; + if (routeValue != null) + { + ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, parameterDescriptor.Value, routeValue); + } + } + + foreach (var item in serverCallContext.HttpContext.Request.Query) + { + if (CanBindQueryStringVariable(serverCallContext, item.Key)) + { + var pathDescriptors = GetPathDescriptors(serverCallContext, requestMessage, item.Key); + + if (pathDescriptors != null) + { + var value = item.Value.Count == 1 ? (object?)item.Value[0] : item.Value; + ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, pathDescriptors, value); + } + } + } + + GrpcServerLog.ReceivedMessage(serverCallContext.Logger); + return (TRequest)requestMessage; + } + catch (Exception ex) + { + GrpcServerLog.ErrorReadingMessage(serverCallContext.Logger, ex); + throw; + } + } + + private static List? GetPathDescriptors(JsonTranscodingServerCallContext serverCallContext, IMessage requestMessage, string path) + { + return serverCallContext.DescriptorInfo.PathDescriptorsCache.GetOrAdd(path, p => + { + ServiceDescriptorHelpers.TryResolveDescriptors(requestMessage.Descriptor, p, out var pathDescriptors); + return pathDescriptors; + }); + } + + public static async Task SendMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions, TResponse message) where TResponse : class + { + var response = serverCallContext.HttpContext.Response; + + try + { + GrpcServerLog.SendingMessage(serverCallContext.Logger); + + object responseBody; + Type responseType; + + if (serverCallContext.DescriptorInfo.ResponseBodyDescriptor != null) + { + // TODO: Support recursive response body? + responseBody = serverCallContext.DescriptorInfo.ResponseBodyDescriptor.Accessor.GetValue((IMessage)message); + responseType = JsonConverterHelper.GetFieldType(serverCallContext.DescriptorInfo.ResponseBodyDescriptor); + } + else + { + responseBody = message; + responseType = message.GetType(); + } + + await JsonRequestHelpers.WriteResponseMessage(response, serverCallContext.RequestEncoding, responseBody, serializerOptions); + + GrpcServerLog.SerializedMessage(serverCallContext.Logger, responseType); + GrpcServerLog.MessageSent(serverCallContext.Logger); + } + catch (Exception ex) + { + GrpcServerLog.ErrorSendingMessage(serverCallContext.Logger, ex); + throw; + } + } + + private static bool CanBindQueryStringVariable(JsonTranscodingServerCallContext serverCallContext, string variable) + { + if (serverCallContext.DescriptorInfo.BodyDescriptor != null) + { + if (serverCallContext.DescriptorInfo.BodyFieldDescriptors == null || serverCallContext.DescriptorInfo.BodyFieldDescriptors.Count == 0) + { + return false; + } + + if (variable == serverCallContext.DescriptorInfo.BodyFieldDescriptorsPath) + { + return false; + } + + if (variable.StartsWith(serverCallContext.DescriptorInfo.BodyFieldDescriptorsPath!, StringComparison.Ordinal)) + { + return false; + } + } + + if (serverCallContext.DescriptorInfo.RouteParameterDescriptors.ContainsKey(variable)) + { + return false; + } + + return true; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Protos/errors.proto b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Protos/errors.proto new file mode 100644 index 00000000000..4fb212c6b69 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Protos/errors.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package grpc.gateway.runtime; +option go_package = "internal"; + +import "google/protobuf/any.proto"; + +// Error is the generic error returned from unary RPCs. +message Error { + string error = 1; + // This is to make the error more compatible with users that expect errors to be Status objects: + // https://github.com/grpc/grpc/blob/master/src/proto/grpc/status/status.proto + // It should be the exact same message as the Error field. + int32 code = 2; + string message = 3; + repeated google.protobuf.Any details = 4; +} + +// StreamError is a response type which is returned when +// streaming rpc returns an error. +message StreamError { + int32 grpc_code = 1; + int32 http_code = 2; + string message = 3; + string http_status = 4; + repeated google.protobuf.Any details = 5; +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj new file mode 100644 index 00000000000..59e0491a3cd --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj @@ -0,0 +1,33 @@ + + + HTTP API for gRPC ASP.NET Core + gRPC RPC HTTP/2 REST + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Shipped.txt b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Shipped.txt new file mode 100644 index 00000000000..7dc5c58110b --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Unshipped.txt b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Unshipped.txt new file mode 100644 index 00000000000..6ec9d5a61a6 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/PublicAPI.Unshipped.txt @@ -0,0 +1,24 @@ +#nullable enable +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.GrpcJsonSettings() -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.IgnoreDefaultValues.get -> bool +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.IgnoreDefaultValues.set -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteEnumsAsIntegers.get -> bool +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteEnumsAsIntegers.set -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteIndented.get -> bool +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteIndented.set -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteInt64sAsStrings.get -> bool +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.WriteInt64sAsStrings.set -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingMetadata +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingMetadata.GrpcJsonTranscodingMetadata(Google.Protobuf.Reflection.MethodDescriptor! methodDescriptor, Google.Api.HttpRule! httpRule) -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingMetadata.HttpRule.get -> Google.Api.HttpRule! +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingMetadata.MethodDescriptor.get -> Google.Protobuf.Reflection.MethodDescriptor! +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions.GrpcJsonTranscodingOptions() -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions.JsonSettings.get -> Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings! +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions.JsonSettings.set -> void +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions.TypeRegistry.get -> Google.Protobuf.Reflection.TypeRegistry! +Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonTranscodingOptions.TypeRegistry.set -> void +Microsoft.Extensions.DependencyInjection.GrpcJsonTranscodingServiceExtensions +static Microsoft.Extensions.DependencyInjection.GrpcJsonTranscodingServiceExtensions.AddJsonTranscoding(this Grpc.AspNetCore.Server.IGrpcServerBuilder! grpcBuilder) -> Grpc.AspNetCore.Server.IGrpcServerBuilder! +static Microsoft.Extensions.DependencyInjection.GrpcJsonTranscodingServiceExtensions.AddJsonTranscoding(this Grpc.AspNetCore.Server.IGrpcServerBuilder! grpcBuilder, System.Action! configureOptions) -> Grpc.AspNetCore.Server.IGrpcServerBuilder! diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerGenOptionsExtensions.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerGenOptionsExtensions.cs new file mode 100644 index 00000000000..8c98b75eec3 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerGenOptionsExtensions.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Xml.XPath; +using Microsoft.AspNetCore.Grpc.Swagger.Internal.XmlComments; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for the gRPC JSON transcoding services. +/// +public static class GrpcSwaggerGenOptionsExtensions +{ + /// + /// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files + /// + /// + /// A factory method that returns XML Comments as an XPathDocument + public static void IncludeGrpcXmlComments( + this SwaggerGenOptions swaggerGenOptions, + Func xmlDocFactory) + { + swaggerGenOptions.IncludeGrpcXmlComments(xmlDocFactory, includeControllerXmlComments: false); + } + + /// + /// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files + /// + /// + /// A factory method that returns XML Comments as an XPathDocument + /// + /// Flag to indicate if controller XML comments (i.e. summary) should be used to assign Tag descriptions. + /// Don't set this flag if you're customizing the default tag for operations via TagActionsBy. + /// + public static void IncludeGrpcXmlComments( + this SwaggerGenOptions swaggerGenOptions, + Func xmlDocFactory, + bool includeControllerXmlComments) + { + var xmlDoc = xmlDocFactory(); + swaggerGenOptions.OperationFilter(xmlDoc); + + if (includeControllerXmlComments) + { + swaggerGenOptions.DocumentFilter(xmlDoc); + } + } + + /// + /// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files + /// + /// + /// An absolute path to the file that contains XML Comments + public static void IncludeGrpcXmlComments( + this SwaggerGenOptions swaggerGenOptions, + string filePath) + { + swaggerGenOptions.IncludeGrpcXmlComments(() => new XPathDocument(filePath)); + } + + /// + /// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files + /// + /// + /// An absolute path to the file that contains XML Comments + /// + /// Flag to indicate if controller XML comments (i.e. summary) should be used to assign Tag descriptions. + /// Don't set this flag if you're customizing the default tag for operations via TagActionsBy. + /// + public static void IncludeGrpcXmlComments( + this SwaggerGenOptions swaggerGenOptions, + string filePath, + bool includeControllerXmlComments) + { + swaggerGenOptions.IncludeGrpcXmlComments(() => new XPathDocument(filePath), includeControllerXmlComments); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerServiceExtensions.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerServiceExtensions.cs new file mode 100644 index 00000000000..44f24666b7f --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/GrpcSwaggerServiceExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Grpc.Swagger.Internal; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for the gRPC JSON transcoding services. +/// +public static class GrpcSwaggerServiceExtensions +{ + /// + /// Adds gRPC JSON transcoding services to the specified . + /// + /// The for adding services. + /// The so that additional calls can be chained. + public static IServiceCollection AddGrpcSwagger(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddGrpc().AddJsonTranscoding(); + + services.TryAddEnumerable(ServiceDescriptor.Transient()); + + // Register default description provider in case MVC is not registered + services.TryAddSingleton(serviceProvider => + { + var actionDescriptorCollectionProvider = serviceProvider.GetService(); + var apiDescriptionProvider = serviceProvider.GetServices(); + + return new ApiDescriptionGroupCollectionProvider( + actionDescriptorCollectionProvider ?? new EmptyActionDescriptorCollectionProvider(), + apiDescriptionProvider); + }); + + // Add or replace contract resolver. + services.Replace(ServiceDescriptor.Transient(s => + { + var serializerOptions = s.GetService>()?.Value?.JsonSerializerOptions ?? new JsonSerializerOptions(); + var innerContractResolver = new JsonSerializerDataContractResolver(serializerOptions); + return new GrpcDataContractResolver(innerContractResolver); + })); + + return services; + } + + // Dummy type that is only used if MVC is not registered in the app + private class EmptyActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider + { + public ActionDescriptorCollection ActionDescriptors { get; } = new ActionDescriptorCollection(new List(), 1); + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcDataContractResolver.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcDataContractResolver.cs new file mode 100644 index 00000000000..ced27c48f20 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcDataContractResolver.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Reflection; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Grpc.Shared; +using Swashbuckle.AspNetCore.SwaggerGen; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal; + +internal class GrpcDataContractResolver : ISerializerDataContractResolver +{ + private readonly ISerializerDataContractResolver _innerContractResolver; + private readonly Dictionary _messageTypeMapping; + private readonly Dictionary _enumTypeMapping; + + public GrpcDataContractResolver(ISerializerDataContractResolver innerContractResolver) + { + _innerContractResolver = innerContractResolver; + _messageTypeMapping = new Dictionary(); + _enumTypeMapping = new Dictionary(); + } + + public DataContract GetDataContractForType(Type type) + { + if (!_messageTypeMapping.TryGetValue(type, out var messageDescriptor)) + { + if (typeof(IMessage).IsAssignableFrom(type)) + { + var property = type.GetProperty("Descriptor", BindingFlags.Public | BindingFlags.Static); + messageDescriptor = property?.GetValue(null) as MessageDescriptor; + + if (messageDescriptor == null) + { + throw new InvalidOperationException($"Couldn't resolve message descriptor for {type}."); + } + + _messageTypeMapping[type] = messageDescriptor; + } + } + + if (messageDescriptor != null) + { + return ConvertMessage(messageDescriptor); + } + + if (type.IsEnum) + { + if (_enumTypeMapping.TryGetValue(type, out var enumDescriptor)) + { + var values = enumDescriptor.Values.Select(v => v.Name).ToList(); + return DataContract.ForPrimitive(type, DataType.String, dataFormat: null, value => + { + var match = enumDescriptor.Values.SingleOrDefault(v => v.Number == (int)value); + var name = match?.Name ?? value.ToString(); + return @"""" + name + @""""; + }); + } + } + + return _innerContractResolver.GetDataContractForType(type); + } + + private DataContract ConvertMessage(MessageDescriptor messageDescriptor) + { + if (ServiceDescriptorHelpers.IsWellKnownType(messageDescriptor)) + { + if (ServiceDescriptorHelpers.IsWrapperType(messageDescriptor)) + { + var field = messageDescriptor.Fields[Int32Value.ValueFieldNumber]; + + return _innerContractResolver.GetDataContractForType(MessageDescriptorHelpers.ResolveFieldType(field)); + } + if (messageDescriptor.FullName == Timestamp.Descriptor.FullName || + messageDescriptor.FullName == Duration.Descriptor.FullName || + messageDescriptor.FullName == FieldMask.Descriptor.FullName) + { + return DataContract.ForPrimitive(messageDescriptor.ClrType, DataType.String, dataFormat: null); + } + if (messageDescriptor.FullName == Struct.Descriptor.FullName) + { + return DataContract.ForObject(messageDescriptor.ClrType, Array.Empty(), extensionDataType: typeof(Value)); + } + if (messageDescriptor.FullName == ListValue.Descriptor.FullName) + { + return DataContract.ForArray(messageDescriptor.ClrType, typeof(Value)); + } + if (messageDescriptor.FullName == Value.Descriptor.FullName) + { + return DataContract.ForPrimitive(messageDescriptor.ClrType, DataType.Unknown, dataFormat: null); + } + if (messageDescriptor.FullName == Any.Descriptor.FullName) + { + var anyProperties = new List + { + new DataProperty("@type", typeof(string), isRequired: true) + }; + return DataContract.ForObject(messageDescriptor.ClrType, anyProperties, extensionDataType: typeof(Value)); + } + } + + var properties = new List(); + + foreach (var field in messageDescriptor.Fields.InFieldNumberOrder()) + { + // Enum type will later be used to call this contract resolver. + // Register the enum type so we know to resolve its names from the descriptor. + if (field.FieldType == FieldType.Enum) + { + _enumTypeMapping.TryAdd(field.EnumType.ClrType, field.EnumType); + } + + Type fieldType; + if (field.IsMap) + { + var mapFields = field.MessageType.Fields.InFieldNumberOrder(); + var valueType = MessageDescriptorHelpers.ResolveFieldType(mapFields[1]); + fieldType = typeof(IDictionary<,>).MakeGenericType(typeof(string), valueType); + } + else if (field.IsRepeated) + { + fieldType = typeof(IList<>).MakeGenericType(MessageDescriptorHelpers.ResolveFieldType(field)); + } + else + { + fieldType = MessageDescriptorHelpers.ResolveFieldType(field); + } + + var propertyName = ServiceDescriptorHelpers.FormatUnderscoreName(field.Name, pascalCase: true, preservePeriod: false); + var propertyInfo = messageDescriptor.ClrType.GetProperty(propertyName); + + properties.Add(new DataProperty(field.JsonName, fieldType, memberInfo: propertyInfo)); + } + + var schema = DataContract.ForObject(messageDescriptor.ClrType, properties: properties); + + return schema; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcHttpApiDescriptionProvider.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcHttpApiDescriptionProvider.cs new file mode 100644 index 00000000000..1cc787de88e --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcHttpApiDescriptionProvider.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Google.Api; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal; + +internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionProvider +{ + private readonly EndpointDataSource _endpointDataSource; + + public GrpcJsonTranscodingDescriptionProvider(EndpointDataSource endpointDataSource) + { + _endpointDataSource = endpointDataSource; + } + + // Executes after ASP.NET Core + public int Order => -900; + + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + var endpoints = _endpointDataSource.Endpoints; + + foreach (var endpoint in endpoints) + { + if (endpoint is RouteEndpoint routeEndpoint) + { + var grpcMetadata = endpoint.Metadata.GetMetadata(); + + if (grpcMetadata != null) + { + var httpRule = grpcMetadata.HttpRule; + var methodDescriptor = grpcMetadata.MethodDescriptor; + + if (ServiceDescriptorHelpers.TryResolvePattern(grpcMetadata.HttpRule, out var pattern, out var verb)) + { + var apiDescription = CreateApiDescription(routeEndpoint, httpRule, methodDescriptor, pattern, verb); + + context.Results.Add(apiDescription); + } + } + } + } + } + + private static ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, HttpRule httpRule, MethodDescriptor methodDescriptor, string pattern, string verb) + { + var apiDescription = new ApiDescription(); + apiDescription.HttpMethod = verb; + apiDescription.ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + // Swagger uses this to group endpoints together. + // Group methods together using the service name. + ["controller"] = methodDescriptor.Service.FullName + }, + EndpointMetadata = routeEndpoint.Metadata.ToList() + }; + apiDescription.RelativePath = pattern.TrimStart('/'); + apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat { MediaType = "application/json" }); + apiDescription.SupportedResponseTypes.Add(new ApiResponseType + { + ApiResponseFormats = { new ApiResponseFormat { MediaType = "application/json" } }, + ModelMetadata = new GrpcModelMetadata(ModelMetadataIdentity.ForType(methodDescriptor.OutputType.ClrType)), + StatusCode = 200 + }); + var explorerSettings = routeEndpoint.Metadata.GetMetadata(); + if (explorerSettings != null) + { + apiDescription.GroupName = explorerSettings.GroupName; + } + + var methodMetadata = routeEndpoint.Metadata.GetMetadata()!; + var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeEndpoint.RoutePattern, methodDescriptor.InputType); + + foreach (var routeParameter in routeParameters) + { + var field = routeParameter.Value.Last(); + var parameterName = ServiceDescriptorHelpers.FormatUnderscoreName(field.Name, pascalCase: true, preservePeriod: false); + var propertyInfo = field.ContainingType.ClrType.GetProperty(parameterName); + + // If from a property, create model as property to get its XML comments. + var identity = propertyInfo != null + ? ModelMetadataIdentity.ForProperty(propertyInfo, MessageDescriptorHelpers.ResolveFieldType(field), field.ContainingType.ClrType) + : ModelMetadataIdentity.ForType(MessageDescriptorHelpers.ResolveFieldType(field)); + + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription + { + Name = routeParameter.Key, + ModelMetadata = new GrpcModelMetadata(identity), + Source = BindingSource.Path, + DefaultValue = string.Empty + }); + } + + var bodyDescriptor = ServiceDescriptorHelpers.ResolveBodyDescriptor(httpRule.Body, methodMetadata.ServiceType, methodDescriptor); + if (bodyDescriptor != null) + { + // If from a property, create model as property to get its XML comments. + var identity = bodyDescriptor.PropertyInfo != null + ? ModelMetadataIdentity.ForProperty(bodyDescriptor.PropertyInfo, bodyDescriptor.Descriptor.ClrType, bodyDescriptor.PropertyInfo.DeclaringType!) + : ModelMetadataIdentity.ForType(bodyDescriptor.Descriptor.ClrType); + + // Or if from a parameter, create model as parameter to get its XML comments. + var parameterDescriptor = bodyDescriptor.ParameterInfo != null + ? new ControllerParameterDescriptor { ParameterInfo = bodyDescriptor.ParameterInfo } + : null; + + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription + { + Name = "Input", + ModelMetadata = new GrpcModelMetadata(identity), + Source = BindingSource.Body, + ParameterDescriptor = parameterDescriptor! + }); + } + + return apiDescription; + } + + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + // no-op + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcModelMetadata.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcModelMetadata.cs new file mode 100644 index 00000000000..75d260fca48 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcModelMetadata.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +#nullable disable + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal; + +internal class GrpcModelMetadata : ModelMetadata +{ + public GrpcModelMetadata(ModelMetadataIdentity identity) : base(identity) + { + IsBindingAllowed = true; + } + + public override IReadOnlyDictionary AdditionalValues { get; } + public override string BinderModelName { get; } + public override Type BinderType { get; } + public override BindingSource BindingSource { get; } + public override bool ConvertEmptyStringToNull { get; } + public override string DataTypeName { get; } + public override string Description { get; } + public override string DisplayFormatString { get; } + public override string DisplayName { get; } + public override string EditFormatString { get; } + public override ModelMetadata ElementMetadata { get; } + public override IEnumerable> EnumGroupedDisplayNamesAndValues { get; } + public override IReadOnlyDictionary EnumNamesAndValues { get; } + public override bool HasNonDefaultEditFormat { get; } + public override bool HideSurroundingHtml { get; } + public override bool HtmlEncode { get; } + public override bool IsBindingAllowed { get; } + public override bool IsBindingRequired { get; } + public override bool IsEnum { get; } + public override bool IsFlagsEnum { get; } + public override bool IsReadOnly { get; } + public override bool IsRequired { get; } + public override ModelBindingMessageProvider ModelBindingMessageProvider { get; } + public override string NullDisplayText { get; } + public override int Order { get; } + public override string Placeholder { get; } + public override ModelPropertyCollection Properties { get; } + public override IPropertyFilterProvider PropertyFilterProvider { get; } + public override Func PropertyGetter { get; } + public override Action PropertySetter { get; } + public override bool ShowForDisplay { get; } + public override bool ShowForEdit { get; } + public override string SimpleDisplayProperty { get; } + public override string TemplateHint { get; } + public override bool ValidateChildren { get; } + public override IReadOnlyList ValidatorMetadata { get; } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/MessageDescriptorHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/MessageDescriptorHelpers.cs new file mode 100644 index 00000000000..f62924c4a60 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/MessageDescriptorHelpers.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Protobuf.Reflection; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal; + +internal static class MessageDescriptorHelpers +{ + public static Type ResolveFieldType(FieldDescriptor field) + { + switch (field.FieldType) + { + case FieldType.Double: + return typeof(double); + case FieldType.Float: + return typeof(float); + case FieldType.Int64: + return typeof(long); + case FieldType.UInt64: + return typeof(ulong); + case FieldType.Int32: + return typeof(int); + case FieldType.Fixed64: + return typeof(long); + case FieldType.Fixed32: + return typeof(int); + case FieldType.Bool: + return typeof(bool); + case FieldType.String: + return typeof(string); + case FieldType.Bytes: + return typeof(string); + case FieldType.UInt32: + return typeof(uint); + case FieldType.SFixed32: + return typeof(int); + case FieldType.SFixed64: + return typeof(long); + case FieldType.SInt32: + return typeof(int); + case FieldType.SInt64: + return typeof(long); + case FieldType.Enum: + return field.EnumType.ClrType; + case FieldType.Message: + return field.MessageType.ClrType; + default: + throw new InvalidOperationException("Unexpected field type: " + field.FieldType); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsDocumentFilter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsDocumentFilter.cs new file mode 100644 index 00000000000..4e14029e6fd --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsDocumentFilter.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Linq; +using System.Xml.XPath; +using Grpc.AspNetCore.Server; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal.XmlComments; + +internal class GrpcXmlCommentsDocumentFilter : IDocumentFilter +{ + private const string MemberXPath = "/doc/members/member[@name='{0}']"; + private const string SummaryTag = "summary"; + + private readonly XPathNavigator _xmlNavigator; + + public GrpcXmlCommentsDocumentFilter(XPathDocument xmlDoc) + { + _xmlNavigator = xmlDoc.CreateNavigator(); + } + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + // Get unique services + var nameAndServiceDescriptor = context.ApiDescriptions + .Select(apiDesc => apiDesc.ActionDescriptor) + .Where(actionDesc => actionDesc != null && (actionDesc.EndpointMetadata?.Any(m => m is GrpcMethodMetadata) ?? false)) + .GroupBy(actionDesc => actionDesc.RouteValues["controller"]!) + .Select(group => new KeyValuePair(group.Key, group.First())); + + foreach (var nameAndType in nameAndServiceDescriptor) + { + var grpcMethodMetadata = nameAndType.Value.EndpointMetadata.OfType().First(); + if (TryAdd(swaggerDoc, nameAndType, grpcMethodMetadata.ServiceType)) + { + continue; + } + + if (grpcMethodMetadata.ServiceType.BaseType?.DeclaringType is { } staticService) + { + if (TryAdd(swaggerDoc, nameAndType, staticService)) + { + continue; + } + } + } + } + + private bool TryAdd(OpenApiDocument swaggerDoc, KeyValuePair nameAndType, Type type) + { + var memberName = XmlCommentsNodeNameHelper.GetMemberNameForType(type); + var typeNode = _xmlNavigator.SelectSingleNode(string.Format(CultureInfo.InvariantCulture, MemberXPath, memberName)); + + if (typeNode != null) + { + var summaryNode = typeNode.SelectSingleNode(SummaryTag); + if (summaryNode != null) + { + if (swaggerDoc.Tags == null) + { + swaggerDoc.Tags = new List(); + } + + swaggerDoc.Tags.Add(new OpenApiTag + { + Name = nameAndType.Key, + Description = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml) + }); + } + return true; + } + + return false; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsOperationFilter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsOperationFilter.cs new file mode 100644 index 00000000000..ea6a8c1f470 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/XmlComments/GrpcXmlCommentsOperationFilter.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Reflection; +using System.Xml.XPath; +using Grpc.AspNetCore.Server; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Internal.XmlComments; + +internal class GrpcXmlCommentsOperationFilter : IOperationFilter +{ + private readonly XPathNavigator _xmlNavigator; + + public GrpcXmlCommentsOperationFilter(XPathDocument xmlDoc) + { + _xmlNavigator = xmlDoc.CreateNavigator(); + } + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var grpcMetadata = context.ApiDescription.ActionDescriptor.EndpointMetadata.OfType().FirstOrDefault(); + if (grpcMetadata == null) + { + return; + } + + var methodInfo = grpcMetadata.ServiceType.GetMethod(grpcMetadata.Method.Name); + if (methodInfo == null) + { + return; + } + + // If method is from a constructed generic type, look for comments from the generic type method + var targetMethod = methodInfo.DeclaringType!.IsConstructedGenericType + ? methodInfo.GetUnderlyingGenericTypeMethod() + : methodInfo; + + if (targetMethod == null) + { + return; + } + + // Base service never has response tags. + ApplyServiceTags(operation, targetMethod.DeclaringType!); + + if (TryApplyMethodTags(operation, targetMethod)) + { + return; + } + + if (targetMethod.IsVirtual && targetMethod.GetBaseDefinition() is { } baseMethod) + { + if (TryApplyMethodTags(operation, baseMethod)) + { + return; + } + } + } + + private void ApplyServiceTags(OpenApiOperation operation, Type controllerType) + { + var typeMemberName = XmlCommentsNodeNameHelper.GetMemberNameForType(controllerType); + var responseNodes = _xmlNavigator.Select($"/doc/members/member[@name='{typeMemberName}']/response"); + ApplyResponseTags(operation, responseNodes); + } + + private bool TryApplyMethodTags(OpenApiOperation operation, MethodInfo methodInfo) + { + var methodMemberName = XmlCommentsNodeNameHelper.GetMemberNameForMethod(methodInfo); + var methodNode = _xmlNavigator.SelectSingleNode($"/doc/members/member[@name='{methodMemberName}']"); + + if (methodNode == null) + { + return false; + } + + var summaryNode = methodNode.SelectSingleNode("summary"); + if (summaryNode != null) + { + operation.Summary = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml); + } + + var remarksNode = methodNode.SelectSingleNode("remarks"); + if (remarksNode != null) + { + operation.Description = XmlCommentsTextHelper.Humanize(remarksNode.InnerXml); + } + + var responseNodes = methodNode.Select("response"); + ApplyResponseTags(operation, responseNodes); + + return true; + } + + private static void ApplyResponseTags(OpenApiOperation operation, XPathNodeIterator responseNodes) + { + while (responseNodes.MoveNext()) + { + var code = responseNodes.Current!.GetAttribute("code", ""); + var response = operation.Responses.ContainsKey(code) + ? operation.Responses[code] + : operation.Responses[code] = new OpenApiResponse(); + + response.Description = XmlCommentsTextHelper.Humanize(responseNodes.Current.InnerXml); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj new file mode 100644 index 00000000000..4ad6d024b91 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj @@ -0,0 +1,18 @@ + + + Swagger for gRPC ASP.NET Core + gRPC RPC HTTP/2 REST Swagger OpenAPI + $(DefaultNetCoreTargetFramework) + false + false + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Shipped.txt b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Shipped.txt new file mode 100644 index 00000000000..7dc5c58110b --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Unshipped.txt b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Unshipped.txt new file mode 100644 index 00000000000..9b52bc42710 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/PublicAPI.Unshipped.txt @@ -0,0 +1,8 @@ +#nullable enable +Microsoft.Extensions.DependencyInjection.GrpcSwaggerGenOptionsExtensions +Microsoft.Extensions.DependencyInjection.GrpcSwaggerServiceExtensions +static Microsoft.Extensions.DependencyInjection.GrpcSwaggerGenOptionsExtensions.IncludeGrpcXmlComments(this Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenOptions! swaggerGenOptions, System.Func! xmlDocFactory) -> void +static Microsoft.Extensions.DependencyInjection.GrpcSwaggerGenOptionsExtensions.IncludeGrpcXmlComments(this Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenOptions! swaggerGenOptions, System.Func! xmlDocFactory, bool includeControllerXmlComments) -> void +static Microsoft.Extensions.DependencyInjection.GrpcSwaggerGenOptionsExtensions.IncludeGrpcXmlComments(this Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenOptions! swaggerGenOptions, string! filePath) -> void +static Microsoft.Extensions.DependencyInjection.GrpcSwaggerGenOptionsExtensions.IncludeGrpcXmlComments(this Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenOptions! swaggerGenOptions, string! filePath, bool includeControllerXmlComments) -> void +static Microsoft.Extensions.DependencyInjection.GrpcSwaggerServiceExtensions.AddGrpcSwagger(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Grpc/JsonTranscoding/src/Shared/.editorconfig b/src/Grpc/JsonTranscoding/src/Shared/.editorconfig new file mode 100644 index 00000000000..81723c72ab4 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/.editorconfig @@ -0,0 +1,3 @@ +[*.{cs,vb}] +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = none \ No newline at end of file diff --git a/src/Grpc/JsonTranscoding/src/Shared/AuthContextHelpers.cs b/src/Grpc/JsonTranscoding/src/Shared/AuthContextHelpers.cs new file mode 100644 index 00000000000..fd266688eac --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/AuthContextHelpers.cs @@ -0,0 +1,72 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Grpc.Core; + +namespace Grpc.Shared; + +internal static class AuthContextHelpers +{ + public static AuthContext CreateAuthContext(X509Certificate2 clientCertificate) + { + // Map X509Certificate2 values to AuthContext. The name/values come BoringSSL via C Core + // https://github.com/grpc/grpc/blob/a3cc5361e6f6eb679ccf5c36ecc6d0ca41b64f4f/src/core/lib/security/security_connector/ssl_utils.cc#L206-L248 + + var properties = new Dictionary>(StringComparer.Ordinal); + + string? peerIdentityPropertyName = null; + + var dnsNames = X509CertificateHelpers.GetDnsFromExtensions(clientCertificate); + foreach (var dnsName in dnsNames) + { + AddProperty(properties, X509CertificateHelpers.X509SubjectAlternativeNameKey, dnsName); + + if (peerIdentityPropertyName == null) + { + peerIdentityPropertyName = X509CertificateHelpers.X509SubjectAlternativeNameKey; + } + } + + var commonName = clientCertificate.GetNameInfo(X509NameType.SimpleName, false); + if (commonName != null) + { + AddProperty(properties, X509CertificateHelpers.X509CommonNameKey, commonName); + if (peerIdentityPropertyName == null) + { + peerIdentityPropertyName = X509CertificateHelpers.X509CommonNameKey; + } + } + + // TODO(JamesNK): Remove nullable override after Grpc.Core.Api update + return new AuthContext(peerIdentityPropertyName!, properties); + + static void AddProperty(Dictionary> properties, string name, string value) + { + if (!properties.TryGetValue(name, out var values)) + { + values = new List(); + properties[name] = values; + } + + values.Add(AuthProperty.Create(name, Encoding.UTF8.GetBytes(value))); + } + } + +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/BindMethodFinder.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/BindMethodFinder.cs new file mode 100644 index 00000000000..4dbc6b8a470 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/BindMethodFinder.cs @@ -0,0 +1,102 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using System.Reflection; +using Grpc.Core; + +namespace Grpc.Shared.Server; + +internal static class BindMethodFinder +{ + private const BindingFlags BindMethodBindingFlags = BindingFlags.Public | BindingFlags.Static; + + internal static MethodInfo? GetBindMethod(Type serviceType) + { + // Prefer finding the bind method using attribute on the generated service + var bindMethodInfo = GetBindMethodUsingAttribute(serviceType); + + if (bindMethodInfo == null) + { + // Fallback to searching for bind method using known type hierarchy that Grpc.Tools generates + bindMethodInfo = GetBindMethodFallback(serviceType); + } + + return bindMethodInfo; + } + + internal static MethodInfo? GetBindMethodUsingAttribute(Type serviceType) + { + Type? currentServiceType = serviceType; + BindServiceMethodAttribute? bindServiceMethod; + do + { + // Search through base types for bind service attribute + // We need to know the base service type because it is used with GetMethod below + bindServiceMethod = currentServiceType.GetCustomAttribute(); + if (bindServiceMethod != null) + { + // Bind method will be public and static + // Two parameters: ServiceBinderBase and the service type + return bindServiceMethod.BindType.GetMethod( + bindServiceMethod.BindMethodName, + BindMethodBindingFlags, + binder: null, + new[] { typeof(ServiceBinderBase), currentServiceType }, + Array.Empty()); + } + } while ((currentServiceType = currentServiceType.BaseType) != null); + + return null; + } + + internal static MethodInfo? GetBindMethodFallback(Type serviceType) + { + // Search for the generated service base class + var baseType = GetServiceBaseType(serviceType); + if (baseType == null) + { + return null; + } + + // We need to call Foo.BindService from the declaring type. + var declaringType = baseType.DeclaringType; + + // The method we want to call is public static void BindService(ServiceBinderBase, BaseType) + return declaringType?.GetMethod( + "BindService", + BindMethodBindingFlags, + binder: null, + new[] { typeof(ServiceBinderBase), baseType }, + Array.Empty()); + } + + private static Type? GetServiceBaseType(Type serviceImplementation) + { + // TService is an implementation of the gRPC service. It ultimately derives from Foo.TServiceBase base class. + // We need to access the static BindService method on Foo which implicitly derives from Object. + var baseType = serviceImplementation.BaseType; + + // Handle services that have multiple levels of inheritence + while (baseType?.BaseType?.BaseType != null) + { + baseType = baseType.BaseType; + } + + return baseType; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/ClientStreamingServerMethodInvoker.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/ClientStreamingServerMethodInvoker.cs new file mode 100644 index 00000000000..72377dc3b86 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/ClientStreamingServerMethodInvoker.cs @@ -0,0 +1,119 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Microsoft.AspNetCore.Http; + +namespace Grpc.Shared.Server; + +/// +/// Client streaming server method invoker. +/// +/// Service type for this method. +/// Request message type for this method. +/// Response message type for this method. +internal sealed class ClientStreamingServerMethodInvoker : ServerMethodInvokerBase + where TRequest : class + where TResponse : class + where TService : class +{ + private readonly ClientStreamingServerMethod _invoker; + private readonly ClientStreamingServerMethod? _pipelineInvoker; + + /// + /// Creates a new instance of . + /// + /// The client streaming method to invoke. + /// The description of the gRPC method. + /// The options used to execute the method. + /// The service activator used to create service instances. + public ClientStreamingServerMethodInvoker( + ClientStreamingServerMethod invoker, + Method method, + MethodOptions options, + IGrpcServiceActivator serviceActivator) + : base(method, options, serviceActivator) + { + _invoker = invoker; + + if (Options.HasInterceptors) + { + var interceptorPipeline = new InterceptorPipelineBuilder(Options.Interceptors); + _pipelineInvoker = interceptorPipeline.ClientStreamingPipeline(ResolvedInterceptorInvoker); + } + } + + private async Task ResolvedInterceptorInvoker(IAsyncStreamReader requestStream, ServerCallContext resolvedContext) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(resolvedContext.GetHttpContext().RequestServices); + return await _invoker( + serviceHandle.Instance, + requestStream, + resolvedContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + + /// + /// Invoke the client streaming method with the specified . + /// + /// The for the current request. + /// The . + /// The reader. + /// A that represents the asynchronous method. The + /// property returns the message. + public async Task Invoke(HttpContext httpContext, ServerCallContext serverCallContext, IAsyncStreamReader requestStream) + { + if (_pipelineInvoker == null) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(httpContext.RequestServices); + return await _invoker( + serviceHandle.Instance, + requestStream, + serverCallContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + else + { + return await _pipelineInvoker( + requestStream, + serverCallContext); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/DuplexStreamingServerMethodInvoker.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/DuplexStreamingServerMethodInvoker.cs new file mode 100644 index 00000000000..8a76507f9cc --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/DuplexStreamingServerMethodInvoker.cs @@ -0,0 +1,122 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Microsoft.AspNetCore.Http; + +namespace Grpc.Shared.Server; + +/// +/// Duplex streaming server method invoker. +/// +/// Service type for this method. +/// Request message type for this method. +/// Response message type for this method. +internal sealed class DuplexStreamingServerMethodInvoker : ServerMethodInvokerBase + where TRequest : class + where TResponse : class + where TService : class +{ + private readonly DuplexStreamingServerMethod _invoker; + private readonly DuplexStreamingServerMethod? _pipelineInvoker; + + /// + /// Creates a new instance of . + /// + /// The duplex streaming method to invoke. + /// The description of the gRPC method. + /// The options used to execute the method. + /// The service activator used to create service instances. + public DuplexStreamingServerMethodInvoker( + DuplexStreamingServerMethod invoker, + Method method, + MethodOptions options, + IGrpcServiceActivator serviceActivator) + : base(method, options, serviceActivator) + { + _invoker = invoker; + + if (Options.HasInterceptors) + { + var interceptorPipeline = new InterceptorPipelineBuilder(Options.Interceptors); + _pipelineInvoker = interceptorPipeline.DuplexStreamingPipeline(ResolvedInterceptorInvoker); + } + } + + private async Task ResolvedInterceptorInvoker(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext resolvedContext) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(resolvedContext.GetHttpContext().RequestServices); + await _invoker( + serviceHandle.Instance, + requestStream, + responseStream, + resolvedContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + + /// + /// Invoke the duplex streaming method with the specified . + /// + /// The for the current request. + /// The . + /// The reader. + /// The writer. + /// A that represents the asynchronous method. + public async Task Invoke(HttpContext httpContext, ServerCallContext serverCallContext, IAsyncStreamReader requestStream, IServerStreamWriter responseStream) + { + if (_pipelineInvoker == null) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(httpContext.RequestServices); + await _invoker( + serviceHandle.Instance, + requestStream, + responseStream, + serverCallContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + else + { + await _pipelineInvoker( + requestStream, + responseStream, + serverCallContext); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/InterceptorPipelineBuilder.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/InterceptorPipelineBuilder.cs new file mode 100644 index 00000000000..180ca91943a --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/InterceptorPipelineBuilder.cs @@ -0,0 +1,186 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using System.Linq; +using Grpc.AspNetCore.Server; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection; + +namespace Grpc.Shared.Server; + +internal class InterceptorPipelineBuilder + where TRequest : class + where TResponse : class +{ + private readonly List _interceptors; + + public InterceptorPipelineBuilder(IReadOnlyList interceptors) + { + _interceptors = interceptors.Select(i => new InterceptorActivatorHandle(i)).ToList(); + } + + public ClientStreamingServerMethod ClientStreamingPipeline(ClientStreamingServerMethod innerInvoker) + { + return BuildPipeline(innerInvoker, BuildInvoker); + + static ClientStreamingServerMethod BuildInvoker(InterceptorActivatorHandle interceptorActivatorHandle, ClientStreamingServerMethod next) + { + return async (requestStream, context) => + { + var serviceProvider = context.GetHttpContext().RequestServices; + var interceptorActivator = interceptorActivatorHandle.GetActivator(serviceProvider); + var interceptorHandle = CreateInterceptor(interceptorActivatorHandle, interceptorActivator, serviceProvider); + + try + { + return await interceptorHandle.Instance.ClientStreamingServerHandler(requestStream, context, next); + } + finally + { + await interceptorActivator.ReleaseAsync(interceptorHandle); + } + }; + } + } + + internal DuplexStreamingServerMethod DuplexStreamingPipeline(DuplexStreamingServerMethod innerInvoker) + { + return BuildPipeline(innerInvoker, BuildInvoker); + + static DuplexStreamingServerMethod BuildInvoker(InterceptorActivatorHandle interceptorActivatorHandle, DuplexStreamingServerMethod next) + { + return async (requestStream, responseStream, context) => + { + var serviceProvider = context.GetHttpContext().RequestServices; + var interceptorActivator = interceptorActivatorHandle.GetActivator(serviceProvider); + var interceptorHandle = CreateInterceptor(interceptorActivatorHandle, interceptorActivator, serviceProvider); + + try + { + await interceptorHandle.Instance.DuplexStreamingServerHandler(requestStream, responseStream, context, next); + } + finally + { + await interceptorActivator.ReleaseAsync(interceptorHandle); + } + }; + } + } + + internal ServerStreamingServerMethod ServerStreamingPipeline(ServerStreamingServerMethod innerInvoker) + { + return BuildPipeline(innerInvoker, BuildInvoker); + + static ServerStreamingServerMethod BuildInvoker(InterceptorActivatorHandle interceptorActivatorHandle, ServerStreamingServerMethod next) + { + return async (request, responseStream, context) => + { + var serviceProvider = context.GetHttpContext().RequestServices; + var interceptorActivator = interceptorActivatorHandle.GetActivator(serviceProvider); + var interceptorHandle = CreateInterceptor(interceptorActivatorHandle, interceptorActivator, serviceProvider); + + try + { + await interceptorHandle.Instance.ServerStreamingServerHandler(request, responseStream, context, next); + } + finally + { + await interceptorActivator.ReleaseAsync(interceptorHandle); + } + }; + } + } + + internal UnaryServerMethod UnaryPipeline(UnaryServerMethod innerInvoker) + { + return BuildPipeline(innerInvoker, BuildInvoker); + + static UnaryServerMethod BuildInvoker(InterceptorActivatorHandle interceptorActivatorHandle, UnaryServerMethod next) + { + return async (request, context) => + { + var serviceProvider = context.GetHttpContext().RequestServices; + var interceptorActivator = interceptorActivatorHandle.GetActivator(serviceProvider); + var interceptorHandle = CreateInterceptor(interceptorActivatorHandle, interceptorActivator, serviceProvider); + + try + { + return await interceptorHandle.Instance.UnaryServerHandler(request, context, next); + } + finally + { + await interceptorActivator.ReleaseAsync(interceptorHandle); + } + }; + } + } + + private T BuildPipeline(T innerInvoker, Func wrapInvoker) + { + // The inner invoker will create the service instance and invoke the method + var resolvedInvoker = innerInvoker; + + // The list is reversed during construction so the first interceptor is built last and invoked first + for (var i = _interceptors.Count - 1; i >= 0; i--) + { + resolvedInvoker = wrapInvoker(_interceptors[i], resolvedInvoker); + } + + return resolvedInvoker; + } + + private static GrpcActivatorHandle CreateInterceptor( + InterceptorActivatorHandle interceptorActivatorHandle, + IGrpcInterceptorActivator interceptorActivator, + IServiceProvider serviceProvider) + { + var interceptorHandle = interceptorActivator.Create(serviceProvider, interceptorActivatorHandle.Registration); + + if (interceptorHandle.Instance == null) + { + throw new InvalidOperationException($"Could not construct Interceptor instance for type {interceptorActivatorHandle.Registration.Type.FullName}"); + } + + return interceptorHandle; + } + + private class InterceptorActivatorHandle + { + public InterceptorRegistration Registration { get; } + + private IGrpcInterceptorActivator? _interceptorActivator; + + public InterceptorActivatorHandle(InterceptorRegistration interceptorRegistration) + { + Registration = interceptorRegistration; + } + + public IGrpcInterceptorActivator GetActivator(IServiceProvider serviceProvider) + { + // Not thread safe. Side effect is resolving the service twice. + if (_interceptorActivator == null) + { + var activatorType = typeof(IGrpcInterceptorActivator<>).MakeGenericType(Registration.Type); + _interceptorActivator = (IGrpcInterceptorActivator)serviceProvider.GetRequiredService(activatorType); + } + + return _interceptorActivator; + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/MethodOptions.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/MethodOptions.cs new file mode 100644 index 00000000000..1ed83b6ea24 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/MethodOptions.cs @@ -0,0 +1,161 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using System.IO.Compression; +using System.Linq; +using Grpc.AspNetCore.Server; +using Grpc.Net.Compression; + +namespace Grpc.Shared.Server; + +/// +/// Options used to execute a gRPC method. +/// +internal sealed class MethodOptions +{ + /// + /// Gets the list of compression providers used to compress and decompress gRPC messages. + /// + public IReadOnlyDictionary CompressionProviders { get; } + + /// + /// Get a collection of interceptors to be executed with every call. Interceptors are executed in order. + /// + public IReadOnlyList Interceptors { get; } + + /// + /// Gets the maximum message size in bytes that can be sent from the server. + /// + public int? MaxSendMessageSize { get; } + + /// + /// Gets the maximum message size in bytes that can be received by the server. + /// + public int? MaxReceiveMessageSize { get; } + + /// + /// Gets a value indicating whether detailed error messages are sent to the peer. + /// Detailed error messages include details from exceptions thrown on the server. + /// + public bool? EnableDetailedErrors { get; } + + /// + /// Gets the compression algorithm used to compress messages sent from the server. + /// The request grpc-accept-encoding header value must contain this algorithm for it to + /// be used. + /// + public string? ResponseCompressionAlgorithm { get; } + + /// + /// Gets the compression level used to compress messages sent from the server. + /// The compression level will be passed to the compression provider. + /// + public CompressionLevel? ResponseCompressionLevel { get; } + + // Fast check for whether the service has any interceptors + internal bool HasInterceptors { get; } + + private MethodOptions( + Dictionary compressionProviders, + InterceptorCollection interceptors, + int? maxSendMessageSize, + int? maxReceiveMessageSize, + bool? enableDetailedErrors, + string? responseCompressionAlgorithm, + CompressionLevel? responseCompressionLevel) + { + CompressionProviders = compressionProviders; + Interceptors = interceptors; + HasInterceptors = interceptors.Count > 0; + MaxSendMessageSize = maxSendMessageSize; + MaxReceiveMessageSize = maxReceiveMessageSize; + EnableDetailedErrors = enableDetailedErrors; + ResponseCompressionAlgorithm = responseCompressionAlgorithm; + ResponseCompressionLevel = responseCompressionLevel; + + if (ResponseCompressionAlgorithm != null) + { + if (!CompressionProviders.TryGetValue(ResponseCompressionAlgorithm, out var _)) + { + throw new InvalidOperationException($"The configured response compression algorithm '{ResponseCompressionAlgorithm}' does not have a matching compression provider."); + } + } + } + + /// + /// Creates method options by merging together the settings the specificed collection. + /// The should be ordered with items arranged in ascending order of precedence. + /// When interceptors from multiple options are merged together they will be executed in reverse order of precendence. + /// + /// A collection of instances, arranged in ascending order of precedence. + /// A new instanced with settings merged from specifid collection. + public static MethodOptions Create(IEnumerable serviceOptions) + { + // This is required to get ensure that service methods without any explicit configuration + // will continue to get the global configuration options + var resolvedCompressionProviders = new Dictionary(StringComparer.Ordinal); + var tempInterceptors = new List(); + int? maxSendMessageSize = null; + int? maxReceiveMessageSize = null; + bool? enableDetailedErrors = null; + string? responseCompressionAlgorithm = null; + CompressionLevel? responseCompressionLevel = null; + + foreach (var options in serviceOptions.Reverse()) + { + AddCompressionProviders(resolvedCompressionProviders, options.CompressionProviders); + tempInterceptors.InsertRange(0, options.Interceptors); + maxSendMessageSize ??= options.MaxSendMessageSize; + maxReceiveMessageSize ??= options.MaxReceiveMessageSize; + enableDetailedErrors ??= options.EnableDetailedErrors; + responseCompressionAlgorithm ??= options.ResponseCompressionAlgorithm; + responseCompressionLevel ??= options.ResponseCompressionLevel; + } + + var interceptors = new InterceptorCollection(); + foreach (var interceptor in tempInterceptors) + { + interceptors.Add(interceptor); + } + + return new MethodOptions + ( + compressionProviders: resolvedCompressionProviders, + interceptors: interceptors, + maxSendMessageSize: maxSendMessageSize, + maxReceiveMessageSize: maxReceiveMessageSize, + enableDetailedErrors: enableDetailedErrors, + responseCompressionAlgorithm: responseCompressionAlgorithm, + responseCompressionLevel: responseCompressionLevel + ); + } + + private static void AddCompressionProviders(Dictionary resolvedProviders, IList? compressionProviders) + { + if (compressionProviders != null) + { + foreach (var compressionProvider in compressionProviders) + { + if (!resolvedProviders.ContainsKey(compressionProvider.EncodingName)) + { + resolvedProviders.Add(compressionProvider.EncodingName, compressionProvider); + } + } + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/ServerMethodInvokerBase.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/ServerMethodInvokerBase.cs new file mode 100644 index 00000000000..8e61c5877e3 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/ServerMethodInvokerBase.cs @@ -0,0 +1,65 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.AspNetCore.Server; +using Grpc.Core; + +namespace Grpc.Shared.Server; + +/// +/// Server method invoker base type. +/// +/// Service type for this method. +/// Request message type for this method. +/// Response message type for this method. +internal abstract class ServerMethodInvokerBase + where TRequest : class + where TResponse : class + where TService : class +{ + /// + /// Gets the description of the gRPC method. + /// + public Method Method { get; } + + /// + /// Gets the options used to execute the method. + /// + public MethodOptions Options { get; } + + /// + /// Gets the service activator used to create service instances. + /// + public IGrpcServiceActivator ServiceActivator { get; } + + /// + /// Creates a new instance of . + /// + /// The description of the gRPC method. + /// The options used to execute the method. + /// The service activator used to create service instances. + private protected ServerMethodInvokerBase( + Method method, + MethodOptions options, + IGrpcServiceActivator serviceActivator) + { + Method = method; + Options = options; + ServiceActivator = serviceActivator; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/ServerStreamingServerMethodInvoker.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/ServerStreamingServerMethodInvoker.cs new file mode 100644 index 00000000000..5b73c4b2230 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/ServerStreamingServerMethodInvoker.cs @@ -0,0 +1,122 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Microsoft.AspNetCore.Http; + +namespace Grpc.Shared.Server; + +/// +/// Server streaming server method invoker. +/// +/// Service type for this method. +/// Request message type for this method. +/// Response message type for this method. +internal sealed class ServerStreamingServerMethodInvoker : ServerMethodInvokerBase + where TRequest : class + where TResponse : class + where TService : class +{ + private readonly ServerStreamingServerMethod _invoker; + private readonly ServerStreamingServerMethod? _pipelineInvoker; + + /// + /// Creates a new instance of . + /// + /// The server streaming method to invoke. + /// The description of the gRPC method. + /// The options used to execute the method. + /// The service activator used to create service instances. + public ServerStreamingServerMethodInvoker( + ServerStreamingServerMethod invoker, + Method method, + MethodOptions options, + IGrpcServiceActivator serviceActivator) + : base(method, options, serviceActivator) + { + _invoker = invoker; + + if (Options.HasInterceptors) + { + var interceptorPipeline = new InterceptorPipelineBuilder(Options.Interceptors); + _pipelineInvoker = interceptorPipeline.ServerStreamingPipeline(ResolvedInterceptorInvoker); + } + } + + private async Task ResolvedInterceptorInvoker(TRequest request, IServerStreamWriter responseStream, ServerCallContext resolvedContext) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(resolvedContext.GetHttpContext().RequestServices); + await _invoker( + serviceHandle.Instance, + request, + responseStream, + resolvedContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + + /// + /// Invoke the server streaming method with the specified . + /// + /// The for the current request. + /// The . + /// The message. + /// The stream writer. + /// A that represents the asynchronous method. + public async Task Invoke(HttpContext httpContext, ServerCallContext serverCallContext, TRequest request, IServerStreamWriter streamWriter) + { + if (_pipelineInvoker == null) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(httpContext.RequestServices); + await _invoker( + serviceHandle.Instance, + request, + streamWriter, + serverCallContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + else + { + await _pipelineInvoker( + request, + streamWriter, + serverCallContext); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/Server/UnaryServerMethodInvoker.cs b/src/Grpc/JsonTranscoding/src/Shared/Server/UnaryServerMethodInvoker.cs new file mode 100644 index 00000000000..b29f08c94f5 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/Server/UnaryServerMethodInvoker.cs @@ -0,0 +1,116 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Microsoft.AspNetCore.Http; + +namespace Grpc.Shared.Server; + +/// +/// Unary server method invoker. +/// +/// Service type for this method. +/// Request message type for this method. +/// Response message type for this method. +internal sealed class UnaryServerMethodInvoker : ServerMethodInvokerBase + where TRequest : class + where TResponse : class + where TService : class +{ + private readonly UnaryServerMethod _invoker; + private readonly UnaryServerMethod? _pipelineInvoker; + + /// + /// Creates a new instance of . + /// + /// The unary method to invoke. + /// The description of the gRPC method. + /// The options used to execute the method. + /// The service activator used to create service instances. + public UnaryServerMethodInvoker( + UnaryServerMethod invoker, + Method method, + MethodOptions options, + IGrpcServiceActivator serviceActivator) + : base(method, options, serviceActivator) + { + _invoker = invoker; + + if (Options.HasInterceptors) + { + var interceptorPipeline = new InterceptorPipelineBuilder(Options.Interceptors); + _pipelineInvoker = interceptorPipeline.UnaryPipeline(ResolvedInterceptorInvoker); + } + } + + private async Task ResolvedInterceptorInvoker(TRequest resolvedRequest, ServerCallContext resolvedContext) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(resolvedContext.GetHttpContext().RequestServices); + return await _invoker(serviceHandle.Instance, resolvedRequest, resolvedContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + + /// + /// Invoke the unary method with the specified . + /// + /// The for the current request. + /// The . + /// The message. + /// A that represents the asynchronous method. The + /// property returns the message. + public async Task Invoke(HttpContext httpContext, ServerCallContext serverCallContext, TRequest request) + { + if (_pipelineInvoker == null) + { + GrpcActivatorHandle serviceHandle = default; + try + { + serviceHandle = ServiceActivator.Create(httpContext.RequestServices); + return await _invoker( + serviceHandle.Instance, + request, + serverCallContext); + } + finally + { + if (serviceHandle.Instance != null) + { + await ServiceActivator.ReleaseAsync(serviceHandle); + } + } + } + else + { + return await _pipelineInvoker( + request, + serverCallContext); + } + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs new file mode 100644 index 00000000000..41b4afbe28b --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs @@ -0,0 +1,407 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Google.Api; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; + +namespace Grpc.Shared; + +internal static class ServiceDescriptorHelpers +{ + private static readonly HashSet WellKnownTypeNames = new HashSet + { + "google/protobuf/any.proto", + "google/protobuf/api.proto", + "google/protobuf/duration.proto", + "google/protobuf/empty.proto", + "google/protobuf/wrappers.proto", + "google/protobuf/timestamp.proto", + "google/protobuf/field_mask.proto", + "google/protobuf/source_context.proto", + "google/protobuf/struct.proto", + "google/protobuf/type.proto", + }; + + internal static bool IsWellKnownType(MessageDescriptor messageDescriptor) => messageDescriptor.File.Package == "google.protobuf" && + WellKnownTypeNames.Contains(messageDescriptor.File.Name); + + internal static bool IsWrapperType(MessageDescriptor m) => + m.File.Package == "google.protobuf" && m.File.Name == "google/protobuf/wrappers.proto"; + + public static ServiceDescriptor? GetServiceDescriptor(Type serviceReflectionType) + { + var property = serviceReflectionType.GetProperty("Descriptor", BindingFlags.Public | BindingFlags.Static); + if (property != null) + { + return (ServiceDescriptor?)property.GetValue(null); + } + + throw new InvalidOperationException($"Get not find Descriptor property on {serviceReflectionType.Name}."); + } + + public static bool TryResolveDescriptors(MessageDescriptor messageDescriptor, string variable, [NotNullWhen(true)]out List? fieldDescriptors) + { + fieldDescriptors = null; + var path = variable.AsSpan(); + MessageDescriptor? currentDescriptor = messageDescriptor; + + while (path.Length > 0) + { + var separator = path.IndexOf('.'); + + string fieldName; + if (separator != -1) + { + fieldName = path.Slice(0, separator).ToString(); + path = path.Slice(separator + 1); + } + else + { + fieldName = path.ToString(); + path = ReadOnlySpan.Empty; + } + + var field = currentDescriptor?.FindFieldByName(fieldName); + if (field == null) + { + fieldDescriptors = null; + return false; + } + + if (fieldDescriptors == null) + { + fieldDescriptors = new List(); + } + + fieldDescriptors.Add(field); + if (field.FieldType == FieldType.Message) + { + currentDescriptor = field.MessageType; + } + else + { + currentDescriptor = null; + } + + } + + return fieldDescriptors != null; + } + + private static object? ConvertValue(object? value, FieldDescriptor descriptor) + { + switch (descriptor.FieldType) + { + case FieldType.Double: + return Convert.ToDouble(value, CultureInfo.InvariantCulture); + case FieldType.Float: + return Convert.ToSingle(value, CultureInfo.InvariantCulture); + case FieldType.Int64: + case FieldType.SInt64: + case FieldType.SFixed64: + return Convert.ToInt64(value, CultureInfo.InvariantCulture); + case FieldType.UInt64: + case FieldType.Fixed64: + return Convert.ToUInt64(value, CultureInfo.InvariantCulture); + case FieldType.Int32: + case FieldType.SInt32: + case FieldType.SFixed32: + return Convert.ToInt32(value, CultureInfo.InvariantCulture); + case FieldType.Bool: + return Convert.ToBoolean(value, CultureInfo.InvariantCulture); + case FieldType.String: + return value; + case FieldType.Bytes: + { + if (value is string s) + { + return ByteString.FromBase64(s); + } + throw new InvalidOperationException("Base64 encoded string required to convert to bytes."); + } + case FieldType.UInt32: + case FieldType.Fixed32: + return Convert.ToUInt32(value, CultureInfo.InvariantCulture); + case FieldType.Enum: + { + if (value is string s) + { + var enumValueDescriptor = int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) + ? descriptor.EnumType.FindValueByNumber(i) + : descriptor.EnumType.FindValueByName(s); + + if (enumValueDescriptor == null) + { + throw new InvalidOperationException($"Invalid value '{s}' for enum type {descriptor.EnumType.Name}."); + } + + return enumValueDescriptor.Number; + } + throw new InvalidOperationException("String required to convert to enum."); + } + case FieldType.Message: + if (IsWrapperType(descriptor.MessageType)) + { + return ConvertValue(value, descriptor.MessageType.FindFieldByName("value")); + } + break; + } + + throw new InvalidOperationException("Unsupported type: " + descriptor.FieldType); + } + + public static void RecursiveSetValue(IMessage currentValue, List pathDescriptors, object? values) + { + for (var i = 0; i < pathDescriptors.Count; i++) + { + var isLast = i == pathDescriptors.Count - 1; + var field = pathDescriptors[i]; + + if (isLast) + { + if (field.IsRepeated) + { + var list = (IList)field.Accessor.GetValue(currentValue); + if (values is StringValues stringValues) + { + foreach (var value in stringValues) + { + list.Add(ConvertValue(value, field)); + } + } + else if (values is IList listValues) + { + foreach (var value in listValues) + { + list.Add(ConvertValue(value, field)); + } + } + else + { + list.Add(ConvertValue(values, field)); + } + } + else + { + if (values is StringValues stringValues) + { + if (stringValues.Count == 1) + { + field.Accessor.SetValue(currentValue, ConvertValue(stringValues[0], field)); + } + else + { + throw new InvalidOperationException("Can't set multiple values onto a non-repeating field."); + } + } + else if (values is IMessage message) + { + field.Accessor.SetValue(currentValue, message); + } + else + { + field.Accessor.SetValue(currentValue, ConvertValue(values, field)); + } + } + } + else + { + var fieldMessage = (IMessage)field.Accessor.GetValue(currentValue); + + if (fieldMessage == null) + { + fieldMessage = (IMessage)Activator.CreateInstance(field.MessageType.ClrType)!; + field.Accessor.SetValue(currentValue, fieldMessage); + } + + currentValue = fieldMessage; + } + } + } + + public static bool TryGetHttpRule(MethodDescriptor methodDescriptor, [NotNullWhen(true)]out HttpRule? httpRule) + { + var options = methodDescriptor.GetOptions(); + httpRule = options?.GetExtension(AnnotationsExtensions.Http); + + return httpRule != null; + } + + public static bool TryResolvePattern(HttpRule http, [NotNullWhen(true)]out string? pattern, [NotNullWhen(true)]out string? verb) + { + switch (http.PatternCase) + { + case HttpRule.PatternOneofCase.Get: + pattern = http.Get; + verb = "GET"; + return true; + case HttpRule.PatternOneofCase.Put: + pattern = http.Put; + verb = "PUT"; + return true; + case HttpRule.PatternOneofCase.Post: + pattern = http.Post; + verb = "POST"; + return true; + case HttpRule.PatternOneofCase.Delete: + pattern = http.Delete; + verb = "DELETE"; + return true; + case HttpRule.PatternOneofCase.Patch: + pattern = http.Patch; + verb = "PATCH"; + return true; + case HttpRule.PatternOneofCase.Custom: + pattern = http.Custom.Path; + verb = http.Custom.Kind; + return true; + default: + pattern = null; + verb = null; + return false; + } + } + + public static Dictionary> ResolveRouteParameterDescriptors(RoutePattern pattern, MessageDescriptor messageDescriptor) + { + var routeParameterDescriptors = new Dictionary>(StringComparer.Ordinal); + foreach (var routeParameter in pattern.Parameters) + { + if (!TryResolveDescriptors(messageDescriptor, routeParameter.Name, out var fieldDescriptors)) + { + throw new InvalidOperationException($"Couldn't find matching field for route parameter '{routeParameter.Name}' on {messageDescriptor.Name}."); + } + + routeParameterDescriptors.Add(routeParameter.Name, fieldDescriptors); + } + + return routeParameterDescriptors; + } + + public static BodyDescriptorInfo? ResolveBodyDescriptor(string body, Type serviceType, MethodDescriptor methodDescriptor) + { + if (!string.IsNullOrEmpty(body)) + { + if (!string.Equals(body, "*", StringComparison.Ordinal)) + { + if (!TryResolveDescriptors(methodDescriptor.InputType, body, out var bodyFieldDescriptors)) + { + throw new InvalidOperationException($"Couldn't find matching field for body '{body}' on {methodDescriptor.InputType.Name}."); + } + var leafDescriptor = bodyFieldDescriptors.Last(); + var propertyName = FormatUnderscoreName(leafDescriptor.Name, pascalCase: true, preservePeriod: false); + var propertyInfo = leafDescriptor.ContainingType.ClrType.GetProperty(propertyName); + + if (leafDescriptor.IsRepeated) + { + // A repeating field isn't a message type. The JSON parser will parse using the containing + // type to get the repeating collection. + return new BodyDescriptorInfo(leafDescriptor.ContainingType, bodyFieldDescriptors, IsDescriptorRepeated: true, propertyInfo); + } + else + { + return new BodyDescriptorInfo(leafDescriptor.MessageType, bodyFieldDescriptors, IsDescriptorRepeated: false, propertyInfo); + } + } + else + { + ParameterInfo? requestParameter = null; + var methodInfo = serviceType.GetMethod(methodDescriptor.Name); + if (methodInfo != null) + { + requestParameter = methodInfo.GetParameters().SingleOrDefault(p => p.Name == "request"); + } + + return new BodyDescriptorInfo(methodDescriptor.InputType, FieldDescriptors: null, IsDescriptorRepeated: false, ParameterInfo: requestParameter); + } + } + + return null; + } + + public record BodyDescriptorInfo( + MessageDescriptor Descriptor, + List? FieldDescriptors, + bool IsDescriptorRepeated, + PropertyInfo? PropertyInfo = null, + ParameterInfo? ParameterInfo = null); + + public static string FormatUnderscoreName(string input, bool pascalCase, bool preservePeriod) + { + var capitalizeNext = pascalCase; + var result = string.Empty; + + for (var i = 0; i < input.Length; i++) + { + if (char.IsLower(input[i])) + { + if (capitalizeNext) + { + result += char.ToUpper(input[i], CultureInfo.InvariantCulture); + } + else + { + result += input[i]; + } + capitalizeNext = false; + } + else if (char.IsUpper(input[i])) + { + if (i == 0 && !capitalizeNext) + { + // Force first letter to lower-case unless explicitly told to + // capitalize it. + result += char.ToLower(input[i], CultureInfo.InvariantCulture); + } + else + { + // Capital letters after the first are left as-is. + result += input[i]; + } + capitalizeNext = false; + } + else if (char.IsDigit(input[i])) + { + result += input[i]; + capitalizeNext = true; + } + else + { + capitalizeNext = true; + if (input[i] == '.' && preservePeriod) + { + result += '.'; + } + } + } + // Add a trailing "_" if the name should be altered. + if (input.Length > 0 && input[input.Length - 1] == '#') + { + result += '_'; + } + return result; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/X509CertificateHelpers.cs b/src/Grpc/JsonTranscoding/src/Shared/X509CertificateHelpers.cs new file mode 100644 index 00000000000..7df63b7d9c5 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/X509CertificateHelpers.cs @@ -0,0 +1,179 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +#pragma warning disable CA1810 // Initialize all static fields inline. + +using System.Globalization; +using System.Security.Cryptography.X509Certificates; + +namespace Grpc.Shared; + +internal static class X509CertificateHelpers +{ + internal const string X509SubjectAlternativeNameId = "2.5.29.17"; + internal const string X509SubjectAlternativeNameKey = "x509_subject_alternative_name"; + internal const string X509CommonNameKey = "x509_common_name"; + + // From https://github.com/dotnet/wcf/blob/08371d989fc1d230dbe9520916644f7a7f5dc954/src/System.Private.ServiceModel/src/System/IdentityModel/Claims/X509CertificateClaimSet.cs#L297-L332 + public static string[] GetDnsFromExtensions(X509Certificate2 cert) + { + foreach (X509Extension ext in cert.Extensions) + { + // Extension is SAN2 + if (ext.Oid?.Value == X509SubjectAlternativeNameConstants.Oid) + { + string asnString = ext.Format(false); + if (string.IsNullOrWhiteSpace(asnString)) + { + return Array.Empty(); + } + + // SubjectAlternativeNames might contain something other than a dNSName, + // so we have to parse through and only use the dNSNames + // + + string[] rawDnsEntries = + asnString.Split(new string[1] { X509SubjectAlternativeNameConstants.Separator }, StringSplitOptions.RemoveEmptyEntries); + + List dnsEntries = new List(); + + for (var i = 0; i < rawDnsEntries.Length; i++) + { + string[] keyval = rawDnsEntries[i].Split(X509SubjectAlternativeNameConstants.Delimiter); + if (string.Equals(keyval[0], X509SubjectAlternativeNameConstants.Identifier, StringComparison.Ordinal)) + { + dnsEntries.Add(keyval[1]); + } + } + + return dnsEntries.ToArray(); + } + } + return Array.Empty(); + } + + // We don't have a strongly typed extension to parse Subject Alt Names, so we have to do a workaround + // to figure out what the identifier, delimiter, and separator is by using a well-known extension + private static class X509SubjectAlternativeNameConstants + { + public const string Oid = "2.5.29.17"; + + private static readonly string? s_identifier; + private static readonly char s_delimiter; + private static readonly string? s_separator; + + private static readonly bool s_successfullyInitialized; + private static readonly Exception? s_initializationException; + + public static string Identifier + { + get + { + EnsureInitialized(); + return s_identifier!; + } + } + + public static char Delimiter + { + get + { + EnsureInitialized(); + return s_delimiter; + } + } + public static string Separator + { + get + { + EnsureInitialized(); + return s_separator!; + } + } + + private static void EnsureInitialized() + { + if (!s_successfullyInitialized) + { + throw new FormatException(string.Format( + CultureInfo.InvariantCulture, + "There was an error detecting the identifier, delimiter, and separator for X509CertificateClaims on this platform.{0}" + + "Detected values were: Identifier: '{1}'; Delimiter:'{2}'; Separator:'{3}'", + Environment.NewLine, + s_identifier, + s_delimiter, + s_separator + ), s_initializationException); + } + } + + // static initializer runs only when one of the properties is accessed + static X509SubjectAlternativeNameConstants() + { + // Extracted a well-known X509Extension + byte[] x509ExtensionBytes = new byte[] { + 48, 36, 130, 21, 110, 111, 116, 45, 114, 101, 97, 108, 45, 115, 117, 98, 106, 101, 99, + 116, 45, 110, 97, 109, 101, 130, 11, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109 + }; + const string subjectName1 = "not-real-subject-name"; + + try + { + X509Extension x509Extension = new X509Extension(Oid, x509ExtensionBytes, true); + string x509ExtensionFormattedString = x509Extension.Format(false); + + // Each OS has a different dNSName identifier and delimiter + // On Windows, dNSName == "DNS Name" (localizable), on Linux, dNSName == "DNS" + // e.g., + // Windows: x509ExtensionFormattedString is: "DNS Name=not-real-subject-name, DNS Name=example.com" + // Linux: x509ExtensionFormattedString is: "DNS:not-real-subject-name, DNS:example.com" + // Parse: + + int delimiterIndex = x509ExtensionFormattedString.IndexOf(subjectName1, StringComparison.Ordinal) - 1; + s_delimiter = x509ExtensionFormattedString[delimiterIndex]; + + // Make an assumption that all characters from the the start of string to the delimiter + // are part of the identifier + s_identifier = x509ExtensionFormattedString.Substring(0, delimiterIndex); + + int separatorFirstChar = delimiterIndex + subjectName1.Length + 1; + int separatorLength = 1; + for (var i = separatorFirstChar + 1; i < x509ExtensionFormattedString.Length; i++) + { + // We advance until the first character of the identifier to determine what the + // separator is. This assumes that the identifier assumption above is correct + if (x509ExtensionFormattedString[i] == s_identifier[0]) + { + break; + } + + separatorLength++; + } + + s_separator = x509ExtensionFormattedString.Substring(separatorFirstChar, separatorLength); + + s_successfullyInitialized = true; + } + catch (Exception ex) + { + s_successfullyInitialized = false; + s_initializationException = ex; + } + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/DynamicGrpcServiceRegistry.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/DynamicGrpcServiceRegistry.cs new file mode 100644 index 00000000000..41682fa427f --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/DynamicGrpcServiceRegistry.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Google.Api; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using IntegrationTestsWebsite.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; + +/// +/// Used by tests to add new service methods. +/// +public class DynamicGrpcServiceRegistry +{ + private readonly DynamicEndpointDataSource _endpointDataSource; + private readonly IServiceProvider _serviceProvider; + + public DynamicGrpcServiceRegistry(DynamicEndpointDataSource endpointDataSource, IServiceProvider serviceProvider) + { + _endpointDataSource = endpointDataSource; + _serviceProvider = serviceProvider; + } + + public Method AddUnaryMethod(UnaryServerMethod callHandler, MethodDescriptor methodDescriptor) + where TRequest : class, IMessage, new() + where TResponse : class, IMessage, new() + { + var method = CreateMethod(MethodType.Unary, methodDescriptor.Name); + + AddServiceCore(c => + { + var unaryMethod = new UnaryServerMethod((service, request, context) => callHandler(request, context)); + var binder = CreateJsonTranscodingBinder(methodDescriptor, c, new DynamicServiceInvokerResolver(unaryMethod)); + + binder.AddMethod(method, callHandler); + }); + + return method; + } + + public Method AddServerStreamingMethod(ServerStreamingServerMethod callHandler, MethodDescriptor methodDescriptor) + where TRequest : class, IMessage, new() + where TResponse : class, IMessage, new() + { + var method = CreateMethod(MethodType.ServerStreaming, methodDescriptor.Name); + + AddServiceCore(c => + { + var serverStreamingMethod = new ServerStreamingServerMethod((service, request, stream, context) => callHandler(request, stream, context)); + var binder = CreateJsonTranscodingBinder(methodDescriptor, c, new DynamicServiceInvokerResolver(serverStreamingMethod)); + + binder.AddMethod(method, callHandler); + }); + + return method; + } + + private void AddServiceCore(Action> action) + { + // Set action for adding dynamic method + var serviceMethodProviders = _serviceProvider.GetServices>().ToList(); + var dynamicServiceModelProvider = serviceMethodProviders.OfType().Single(); + dynamicServiceModelProvider.CreateMethod = action; + + // Add to dynamic endpoint route builder + var routeBuilder = new DynamicEndpointRouteBuilder(_serviceProvider); + routeBuilder.MapGrpcService(); + + var endpoints = routeBuilder.DataSources.SelectMany(ds => ds.Endpoints).ToList(); + _endpointDataSource.AddEndpoints(endpoints); + } + + private Method CreateMethod(MethodType methodType, string methodName) + where TRequest : class, IMessage, new() + where TResponse : class, IMessage, new() + { + return new Method( + methodType, + typeof(DynamicService).Name, + methodName, + CreateMarshaller(), + CreateMarshaller()); + } + + private Marshaller CreateMarshaller() + where TMessage : class, IMessage, new() + { + return new Marshaller( + m => m.ToByteArray(), + d => + { + var m = new TMessage(); + m.MergeFrom(d); + return m; + }); + } + + private class DynamicEndpointRouteBuilder : IEndpointRouteBuilder + { + public DynamicEndpointRouteBuilder(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + } + + public IServiceProvider ServiceProvider { get; } + + public ICollection DataSources { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() + { + return new ApplicationBuilder(ServiceProvider); + } + } + + private JsonTranscodingProviderServiceBinder CreateJsonTranscodingBinder( + MethodDescriptor methodDescriptor, + ServiceMethodProviderContext context, + DynamicServiceInvokerResolver invokerResolver) + where TRequest : class, IMessage, new() + where TResponse : class, IMessage, new() + { + var JsonTranscodingOptions = _serviceProvider.GetRequiredService>().Value; + var binder = new JsonTranscodingProviderServiceBinder( + context, + invokerResolver, + methodDescriptor.Service, + _serviceProvider.GetRequiredService>().Value, + _serviceProvider.GetRequiredService>>().Value, + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService>(), + JsonTranscodingOptions); + + return binder; + } + + private class DynamicServiceInvokerResolver : IServiceInvokerResolver + { + private readonly Delegate _testDelegate; + + public DynamicServiceInvokerResolver(Delegate testDelegate) + { + _testDelegate = testDelegate; + } + + public (TDelegate invoker, List metadata) CreateModelCore( + string methodName, + Type[] methodParameters, + string verb, + HttpRule httpRule, + MethodDescriptor methodDescriptor) where TDelegate : Delegate + { + return ((TDelegate)_testDelegate, new List()); + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/ForwardingLoggerProvider.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/ForwardingLoggerProvider.cs new file mode 100644 index 00000000000..6b43341b476 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/ForwardingLoggerProvider.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; + +internal class ForwardingLoggerProvider : ILoggerProvider +{ + private readonly LogMessage _logAction; + + public ForwardingLoggerProvider(LogMessage logAction) + { + _logAction = logAction; + } + + public ILogger CreateLogger(string categoryName) + { + return new ForwardingLogger(categoryName, _logAction); + } + + public void Dispose() + { + } + + internal class ForwardingLogger : ILogger + { + private readonly string _categoryName; + private readonly LogMessage _logAction; + + public ForwardingLogger(string categoryName, LogMessage logAction) + { + _categoryName = categoryName; + _logAction = logAction; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null!; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + _logAction(logLevel, _categoryName, eventId, formatter(state, exception), exception); + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestContext.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestContext.cs new file mode 100644 index 00000000000..72841dd0c32 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestContext.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; + +public delegate void LogMessage(LogLevel logLevel, string categoryName, EventId eventId, string message, Exception? exception); + +internal class GrpcTestContext : IDisposable where TStartup : class +{ + private readonly Stopwatch _stopwatch; + private readonly GrpcTestFixture _fixture; + private readonly ITestOutputHelper _outputHelper; + + public GrpcTestContext(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + { + _stopwatch = Stopwatch.StartNew(); + _fixture = fixture; + _outputHelper = outputHelper; + _fixture.LoggedMessage += WriteMessage; + } + + private void WriteMessage(LogLevel logLevel, string category, EventId eventId, string message, Exception? exception) + { + _outputHelper.WriteLine($"{_stopwatch.Elapsed.TotalSeconds:N3}s {category} - {logLevel}: {message}"); + } + + public void Dispose() + { + _fixture.LoggedMessage -= WriteMessage; + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestFixture.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestFixture.cs new file mode 100644 index 00000000000..6a1b75f8c99 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/GrpcTestFixture.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; + +public class GrpcTestFixture : IDisposable where TStartup : class +{ + private TestServer? _server; + private IHost? _host; + private HttpMessageHandler? _handler; + private Action? _configureWebHost; + private DynamicGrpcServiceRegistry? _dynamicGrpc; + + public event LogMessage? LoggedMessage; + public DynamicGrpcServiceRegistry DynamicGrpc + { + get + { + EnsureServer(); + return _dynamicGrpc!; + } + } + + public GrpcTestFixture() + { + LoggerFactory = new LoggerFactory(); + LoggerFactory.AddProvider(new ForwardingLoggerProvider((logLevel, category, eventId, message, exception) => + { + LoggedMessage?.Invoke(logLevel, category, eventId, message, exception); + })); + } + + public void ConfigureWebHost(Action configure) + { + _configureWebHost = configure; + } + + private void EnsureServer() + { + if (_host == null) + { + var builder = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(LoggerFactory); + // Registers a service for tests to add new methods + services.AddSingleton(); + }) + .ConfigureWebHostDefaults(webHost => + { + webHost + .UseTestServer() + .UseStartup(); + + _configureWebHost?.Invoke(webHost); + }); + _host = builder.Start(); + _server = _host.GetTestServer(); + _handler = _server.CreateHandler(); + _dynamicGrpc = _server.Services.GetRequiredService(); + } + } + + public LoggerFactory LoggerFactory { get; } + + public HttpMessageHandler Handler + { + get + { + EnsureServer(); + return _handler!; + } + } + + public void Dispose() + { + _handler?.Dispose(); + _host?.Dispose(); + _server?.Dispose(); + } + + public IDisposable GetTestContext(ITestOutputHelper outputHelper) + { + return new GrpcTestContext(this, outputHelper); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/SyncPoint.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/SyncPoint.cs new file mode 100644 index 00000000000..653069df081 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/SyncPoint.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +public class SyncPoint +{ + private readonly TaskCompletionSource _atSyncPoint; + private readonly TaskCompletionSource _continueFromSyncPoint; + + public SyncPoint(bool runContinuationsAsynchronously = true) + { + var taskCreationOptions = runContinuationsAsynchronously ? TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None; + + _atSyncPoint = new TaskCompletionSource(taskCreationOptions); + _continueFromSyncPoint = new TaskCompletionSource(taskCreationOptions); + } + + /// + /// Waits for the code-under-test to reach . + /// + /// + public Task WaitForSyncPoint() => _atSyncPoint.Task; + + /// + /// Cancel waiting for the code-under-test to reach . + /// + /// A cancellation token. + public void CancelWaitForSyncPoint(CancellationToken cancellationToken) => _atSyncPoint.TrySetCanceled(cancellationToken); + + /// + /// Releases the code-under-test to continue past where it waited for . + /// + public void Continue() => _continueFromSyncPoint.TrySetResult(null); + + /// + /// Used by the code-under-test to wait for the test code to sync up. + /// + /// + /// This code will unblock and then block waiting for to be called. + /// + /// + public Task WaitToContinue() + { + _atSyncPoint.TrySetResult(null); + return _continueFromSyncPoint.Task; + } + + public static Func Create(out SyncPoint syncPoint, bool runContinuationsAsynchronously = true) + { + var handler = Create(1, out var syncPoints, runContinuationsAsynchronously); + syncPoint = syncPoints[0]; + return handler; + } + + /// + /// Creates a re-entrant function that waits for sync points in sequence. + /// + /// The number of sync points to expect + /// The objects that can be used to coordinate the sync point + /// + public static Func Create(int count, out SyncPoint[] syncPoints, bool runContinuationsAsynchronously = true) + { + // Need to use a local so the closure can capture it. You can't use out vars in a closure. + var localSyncPoints = new SyncPoint[count]; + for (var i = 0; i < count; i += 1) + { + localSyncPoints[i] = new SyncPoint(runContinuationsAsynchronously); + } + + syncPoints = localSyncPoints; + + var counter = 0; + return () => + { + if (counter >= localSyncPoints.Length) + { + return Task.CompletedTask; + } + else + { + var syncPoint = localSyncPoints[counter]; + + counter += 1; + return syncPoint.WaitToContinue(); + } + }; + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TaskExtensions.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TaskExtensions.cs new file mode 100644 index 00000000000..82c154488c4 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TaskExtensions.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +internal static class TaskExtensions +{ +#if NET472 + // Allow AsTask in tests where the Task/ValueTask is already a task. + public static Task AsTask(this Task task) + { + return task; + } + + public static Task AsTask(this Task task) + { + return task; + } +#endif + + public static Task DefaultTimeout(this Task task, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return task.TimeoutAfter(TimeSpan.FromSeconds(5), filePath, lineNumber); + } + + public static Task DefaultTimeout(this Task task, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return task.TimeoutAfter(TimeSpan.FromSeconds(5), filePath, lineNumber); + } + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + return await task; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + return await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + await task; + return; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + private static string CreateMessage(TimeSpan timeout, string? filePath, int lineNumber) + => string.IsNullOrEmpty(filePath) + ? $"The operation timed out after reaching the limit of {timeout.TotalMilliseconds}ms." + : $"The operation at {filePath}:{lineNumber} timed out after reaching the limit of {timeout.TotalMilliseconds}ms."; + +#if !NET472 + public static IAsyncEnumerable DefaultTimeout(this IAsyncEnumerable enumerable, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return enumerable.TimeoutAfter(TimeSpan.FromSeconds(5), filePath, lineNumber); + } + + public static IAsyncEnumerable TimeoutAfter(this IAsyncEnumerable enumerable, TimeSpan timeout, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return new TimeoutAsyncEnumerable(enumerable, timeout, filePath, lineNumber); + } + + private class TimeoutAsyncEnumerable : IAsyncEnumerable + { + private readonly IAsyncEnumerable _inner; + private readonly TimeSpan _timeout; + private readonly string? _filePath; + private readonly int _lineNumber; + + public TimeoutAsyncEnumerable(IAsyncEnumerable inner, TimeSpan timeout, string? filePath, int lineNumber) + { + _inner = inner; + _timeout = timeout; + _filePath = filePath; + _lineNumber = lineNumber; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new TimeoutAsyncEnumerator( + _inner.GetAsyncEnumerator(cancellationToken), + _timeout, + _filePath, + _lineNumber); + } + } + + private class TimeoutAsyncEnumerator : IAsyncEnumerator + { + private readonly IAsyncEnumerator _enumerator; + private readonly TimeSpan _timeout; + private readonly string? _filePath; + private readonly int _lineNumber; + + public TimeoutAsyncEnumerator(IAsyncEnumerator enumerator, TimeSpan timeout, string? filePath, int lineNumber) + { + _enumerator = enumerator; + _timeout = timeout; + _filePath = filePath; + _lineNumber = lineNumber; + } + + public T Current => _enumerator.Current; + + public ValueTask DisposeAsync() + { + return _enumerator.DisposeAsync(); + } + + public ValueTask MoveNextAsync() + { + return new ValueTask(_enumerator.MoveNextAsync().AsTask().TimeoutAfter(_timeout, _filePath, _lineNumber)); + } + } +#endif +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs new file mode 100644 index 00000000000..c2319e38bf8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.Core.Interceptors; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +internal static class TestHelpers +{ + public static DefaultHttpContext CreateHttpContext(CancellationToken cancellationToken = default, Stream? bodyStream = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(typeof(IGrpcInterceptorActivator<>), typeof(TestInterceptorActivator<>)); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("localhost"); + httpContext.RequestServices = serviceProvider; + httpContext.Response.Body = bodyStream ?? new MemoryStream(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1"); + httpContext.Features.Set(new HttpRequestLifetimeFeature(cancellationToken)); + return httpContext; + } + + private class TestInterceptorActivator : IGrpcInterceptorActivator where T : Interceptor + { + public GrpcActivatorHandle Create(IServiceProvider serviceProvider, InterceptorRegistration interceptorRegistration) + { + return new GrpcActivatorHandle(Activator.CreateInstance(), created: true, state: null); + } + + public ValueTask ReleaseAsync(GrpcActivatorHandle interceptor) + { + return default; + } + } + + private class HttpRequestLifetimeFeature : IHttpRequestLifetimeFeature + { + public HttpRequestLifetimeFeature(CancellationToken cancellationToken) + { + RequestAborted = cancellationToken; + } + + public CancellationToken RequestAborted { get; set; } + + public void Abort() + { + } + } + + public static CallHandlerDescriptorInfo CreateDescriptorInfo( + FieldDescriptor? responseBodyDescriptor = null, + Dictionary>? routeParameterDescriptors = null, + MessageDescriptor? bodyDescriptor = null, + bool? bodyDescriptorRepeated = null, + List? bodyFieldDescriptors = null) + { + return new CallHandlerDescriptorInfo( + responseBodyDescriptor, + bodyDescriptor, + bodyDescriptorRepeated ?? false, + bodyFieldDescriptors, + routeParameterDescriptors ?? new Dictionary>()); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/IntegrationTestBase.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/IntegrationTestBase.cs new file mode 100644 index 00000000000..88b9a387f17 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/IntegrationTestBase.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using IntegrationTestsWebsite; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests; + +public class IntegrationTestBase : IClassFixture>, IDisposable +{ + private HttpClient? _channel; + private IDisposable? _testContext; + + protected GrpcTestFixture Fixture { get; set; } + + protected ILoggerFactory LoggerFactory => Fixture.LoggerFactory; + + protected HttpClient Channel => _channel ??= CreateChannel(); + + protected HttpClient CreateChannel() + { + return new HttpClient(Fixture.Handler); + } + + public IntegrationTestBase(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + { + Fixture = fixture; + _testContext = Fixture.GetTestContext(outputHelper); + } + + public void Dispose() + { + _testContext?.Dispose(); + _channel = null; + } +} + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj new file mode 100644 index 00000000000..251c7a38a33 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.csproj @@ -0,0 +1,16 @@ + + + $(DefaultNetCoreTargetFramework) + enable + + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/ServerStreamingTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/ServerStreamingTests.cs new file mode 100644 index 00000000000..d58d49fd792 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/ServerStreamingTests.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using System.Text.Json; +using Grpc.Core; +using IntegrationTestsWebsite; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests; + +public class ServerStreamingTests : IntegrationTestBase +{ + public ServerStreamingTests(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + : base(fixture, outputHelper) + { + } + + [Fact] + public async Task GetWithRouteParameter_WriteOne_SuccessResult() + { + // Arrange + async Task ServerStreamingMethod(HelloRequest request, IServerStreamWriter writer, ServerCallContext context) + { + await writer.WriteAsync(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddServerStreamingMethod( + ServerStreamingMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/test").DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + using var result = await JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("Hello test!", result.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task GetWithRouteParameter_WriteMultiple_SuccessResult() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async Task ServerStreamingMethod(HelloRequest request, IServerStreamWriter writer, ServerCallContext context) + { + await writer.WriteAsync(new HelloReply { Message = $"Hello {request.Name} 1!" }); + await tcs.Task; + await writer.WriteAsync(new HelloReply { Message = $"Hello {request.Name} 2!" }); + } + var method = Fixture.DynamicGrpc.AddServerStreamingMethod( + ServerStreamingMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act 1 + var response = await client.GetAsync("/v1/greeter/test", HttpCompletionOption.ResponseHeadersRead).DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + var streamReader = new StreamReader(responseStream); + + var line1 = await streamReader.ReadLineAsync(); + using var result1 = JsonDocument.Parse(line1!); + + // Assert 1 + Assert.Equal("Hello test 1!", result1.RootElement.GetProperty("message").GetString()); + + // Act 2 + tcs.SetResult(); + var line2 = await streamReader.ReadLineAsync(); + using var result2 = JsonDocument.Parse(line2!); + + // Assert 2 + Assert.Equal("Hello test 2!", result2.RootElement.GetProperty("message").GetString()); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/UnaryTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/UnaryTests.cs new file mode 100644 index 00000000000..4873ecc1315 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/UnaryTests.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Grpc.Core; +using IntegrationTestsWebsite; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests; + +public class UnaryTests : IntegrationTestBase +{ + public UnaryTests(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + : base(fixture, outputHelper) + { + } + + [Fact] + public async Task GetWithRouteParameter_MatchUrl_SuccessResult() + { + // Arrange + Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/test").DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + using var result = await JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("Hello test!", result.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task WriteResponseHeadersAsync_SendHeaders_HeadersSentBeforeResult() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + await context.WriteResponseHeadersAsync(new Metadata + { + new Metadata.Entry("test", "value!") + }); + + await tcs.Task; + + return new HelloReply { Message = $"Hello {request.Name}!" }; + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/test", HttpCompletionOption.ResponseHeadersRead).DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + var resultTask = JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("value!", response.Headers.GetValues("test").Single()); + Assert.False(resultTask.IsCompleted); + + tcs.SetResult(); + using var result = await resultTask.DefaultTimeout(); + + Assert.Equal("Hello test!", result.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task WriteResponseHeadersAsync_CallTwice_Error() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + await context.WriteResponseHeadersAsync(new Metadata + { + new Metadata.Entry("test", "value!") + }); + + await tcs.Task; + + await context.WriteResponseHeadersAsync(new Metadata + { + new Metadata.Entry("test", "value 2!") + }); + + return new HelloReply { Message = $"Hello {request.Name}!" }; + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/test", HttpCompletionOption.ResponseHeadersRead).DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + var resultTask = JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("value!", response.Headers.GetValues("test").Single()); + Assert.False(resultTask.IsCompleted); + + tcs.SetResult(); + using var result = await resultTask.DefaultTimeout(); + + Assert.Equal("Exception was thrown by handler. InvalidOperationException: Response headers can only be sent once per call.", result.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task AuthContext_BasicRequest_Unauthenticated() + { + // Arrange + AuthContext? authContext = null; + Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + authContext = context.AuthContext; + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHello")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/test").DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + using var result = await JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.False(authContext!.IsPeerAuthenticated); + Assert.Equal("Hello test!", result.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData(null)] + [InlineData("utf-8")] + [InlineData("utf-16")] + [InlineData("latin1")] + public async Task Request_SupportedCharset_Success(string? charset) + { + // Arrange + Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHelloPost")); + + var encoding = JsonRequestHelpers.GetEncodingFromCharset(charset); + var contentType = charset != null + ? "application/json; charset=" + charset + : "application/json"; + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + var requestMessage = new HelloRequest { Name = "test" }; + var content = new ByteArrayContent((encoding ?? Encoding.UTF8).GetBytes(requestMessage.ToString())); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + + // Act + var response = await client.PostAsync("/v1/greeter", content).DefaultTimeout(); + var responseText = await response.Content.ReadAsStringAsync(); + using var result = JsonDocument.Parse(responseText); + + // Assert + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + Assert.Equal(encoding?.WebName ?? "utf-8", response.Content.Headers.ContentType!.CharSet); + Assert.Equal("Hello test!", result.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData("FAKE", "InvalidOperationException: Unable to read the request as JSON because the request content type charset 'FAKE' is not a known encoding.")] + [InlineData("UTF-7", "InvalidOperationException: Unable to read the request as JSON because the request content type charset 'UTF-7' is not a known encoding.")] + public async Task Request_UnsupportedCharset_Error(string? charset, string errorMessage) + { + // Arrange + Task UnaryMethod(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHelloPost")); + + var contentType = "application/json; charset=" + charset; + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + var requestMessage = new HelloRequest { Name = "test" }; + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(requestMessage.ToString())); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + + // Act + var response = await client.PostAsync("/v1/greeter", content).DefaultTimeout(); + var responseText = await response.Content.ReadAsStringAsync(); + using var result = JsonDocument.Parse(responseText); + + // Assert + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + Assert.Equal("utf-8", response.Content.Headers.ContentType!.CharSet); + Assert.Contains(errorMessage, result.RootElement.GetProperty("message").GetString()); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterReadTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterReadTests.cs new file mode 100644 index 00000000000..6275a52f8df --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterReadTests.cs @@ -0,0 +1,479 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; +using Transcoding; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.ConverterTests; + +public class JsonConverterReadTests +{ + private readonly ITestOutputHelper _output; + + public JsonConverterReadTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void ReadObjectProperties() + { + var json = @"{ + ""name"": ""test"", + ""age"": 1 +}"; + + AssertReadJson(json); + } + + [Fact] + public void RepeatedStrings() + { + var json = @"{ + ""name"": ""test"", + ""repeatedStrings"": [ + ""One"", + ""Two"", + ""Three"" + ] +}"; + + AssertReadJson(json); + } + + [Fact] + public void DataTypes_DefaultValues() + { + var json = @"{ + ""singleInt32"": 0, + ""singleInt64"": ""0"", + ""singleUint32"": 0, + ""singleUint64"": ""0"", + ""singleSint32"": 0, + ""singleSint64"": ""0"", + ""singleFixed32"": 0, + ""singleFixed64"": ""0"", + ""singleSfixed32"": 0, + ""singleSfixed64"": ""0"", + ""singleFloat"": 0, + ""singleDouble"": 0, + ""singleBool"": false, + ""singleString"": """", + ""singleBytes"": """", + ""singleEnum"": ""NESTED_ENUM_UNSPECIFIED"" +}"; + + AssertReadJson(json); + } + + [Theory] + [InlineData(1)] + [InlineData(-1)] + [InlineData(100)] + public void Enum_ReadNumber(int value) + { + var json = @"{ ""singleEnum"": " + value + " }"; + + AssertReadJson(json); + } + + [Fact] + public void Timestamp_Nested() + { + var json = @"{ ""timestampValue"": ""2020-12-01T00:30:00Z"" }"; + + AssertReadJson(json); + } + + [Fact] + public void Duration_Nested() + { + var json = @"{ ""durationValue"": ""43200s"" }"; + + AssertReadJson(json); + } + + [Fact] + public void Value_Nested() + { + var json = @"{ + ""valueValue"": { + ""enabled"": true, + ""metadata"": [ + ""value1"", + ""value2"" + ] + } +}"; + + AssertReadJson(json); + } + + [Fact] + public void Value_Root() + { + var json = @"{ + ""enabled"": true, + ""metadata"": [ + ""value1"", + ""value2"" + ] +}"; + + AssertReadJson(json); + } + + [Fact] + public void Struct_Nested() + { + var json = @"{ + ""structValue"": { + ""enabled"": true, + ""metadata"": [ + ""value1"", + ""value2"" + ] + } +}"; + + AssertReadJson(json); + } + + [Fact] + public void Struct_Root() + { + var json = @"{ + ""enabled"": true, + ""metadata"": [ + ""value1"", + ""value2"" + ] +}"; + + AssertReadJson(json); + } + + [Fact] + public void ListValue_Nested() + { + var json = @"{ + ""listValue"": [ + true, + ""value1"", + ""value2"" + ] +}"; + + AssertReadJson(json); + } + + [Fact] + public void ListValue_Root() + { + var json = @"[ + true, + ""value1"", + ""value2"" +]"; + + AssertReadJson(json); + } + + [Fact] + public void Int64_ReadNumber() + { + var json = @"{ + ""singleInt64"": 1, + ""singleUint64"": 2, + ""singleSint64"": 3, + ""singleFixed64"": 4, + ""singleSfixed64"": 5 +}"; + + AssertReadJson(json); + } + + [Fact] + public void RepeatedDoubleValues() + { + var json = @"{ + ""repeatedDoubleValues"": [ + 1, + 1.1 + ] +}"; + + AssertReadJson(json); + } + + [Fact] + public void Any() + { + var json = @"{ + ""@type"": ""type.googleapis.com/transcoding.HelloRequest"", + ""name"": ""In any!"" +}"; + + var any = AssertReadJson(json); + var helloRequest = any.Unpack(); + Assert.Equal("In any!", helloRequest.Name); + } + + [Fact] + public void Any_WellKnownType_Timestamp() + { + var json = @"{ + ""@type"": ""type.googleapis.com/google.protobuf.Timestamp"", + ""value"": ""1970-01-01T00:00:00Z"" +}"; + + var any = AssertReadJson(json); + var timestamp = any.Unpack(); + Assert.Equal(DateTimeOffset.UnixEpoch, timestamp.ToDateTimeOffset()); + } + + [Fact] + public void Any_WellKnownType_Int32() + { + var json = @"{ + ""@type"": ""type.googleapis.com/google.protobuf.Int32Value"", + ""value"": 2147483647 +}"; + + var any = AssertReadJson(json); + var value = any.Unpack(); + Assert.Equal(2147483647, value.Value); + } + + [Fact] + public void MapMessages() + { + var json = @"{ + ""mapMessage"": { + ""name1"": { + ""subfield"": ""value1"" + }, + ""name2"": { + ""subfield"": ""value2"" + } + } +}"; + + AssertReadJson(json); + } + + [Fact] + public void MapKeyBool() + { + var json = @"{ + ""mapKeybool"": { + ""true"": ""value1"", + ""false"": ""value2"" + } +}"; + + AssertReadJson(json); + } + + [Fact] + public void MapKeyInt() + { + var json = @"{ + ""mapKeyint"": { + ""-1"": ""value1"", + ""0"": ""value3"" + } +}"; + + AssertReadJson(json); + } + + [Fact] + public void OneOf_Success() + { + var json = @"{ + ""oneofName1"": ""test"" +}"; + + AssertReadJson(json); + } + + [Fact] + public void OneOf_Failure() + { + var json = @"{ + ""oneofName1"": ""test"", + ""oneofName2"": ""test"" +}"; + + AssertReadJsonError(json, ex => Assert.Equal("Multiple values specified for oneof oneof_test", ex.Message.TrimEnd('.'))); + } + + [Fact] + public void NullableWrappers_NaN() + { + var json = @"{ + ""doubleValue"": ""NaN"" +}"; + + AssertReadJson(json); + } + + [Fact] + public void NullableWrappers_Null() + { + var json = @"{ + ""stringValue"": null, + ""int32Value"": null, + ""int64Value"": null, + ""floatValue"": null, + ""doubleValue"": null, + ""boolValue"": null, + ""uint32Value"": null, + ""uint64Value"": null, + ""bytesValue"": null +}"; + + AssertReadJson(json); + } + + [Fact] + public void NullableWrappers() + { + var json = @"{ + ""stringValue"": ""A string"", + ""int32Value"": 1, + ""int64Value"": ""2"", + ""floatValue"": 1.2, + ""doubleValue"": 1.1, + ""boolValue"": true, + ""uint32Value"": 3, + ""uint64Value"": ""4"", + ""bytesValue"": ""SGVsbG8gd29ybGQ="" +}"; + + AssertReadJson(json); + } + + [Fact] + public void NullValue_Default_Null() + { + var json = @"{ ""nullValue"": null }"; + + AssertReadJson(json); + } + + [Fact] + public void NullValue_Default_String() + { + var json = @"{ ""nullValue"": ""NULL_VALUE"" }"; + + AssertReadJson(json); + } + + [Fact] + public void NullValue_NonDefaultValue_Int() + { + var json = @"{ ""nullValue"": 1 }"; + + AssertReadJson(json); + } + + [Fact] + public void NullValue_NonDefaultValue_String() + { + var json = @"{ ""nullValue"": ""MONKEY"" }"; + + AssertReadJsonError(json, ex => Assert.Equal("Invalid enum value: MONKEY for enum type: google.protobuf.NullValue", ex.Message)); + } + + [Fact] + public void FieldMask_Nested() + { + var json = @"{ ""fieldMaskValue"": ""value1,value2,value3.nestedValue"" }"; + + AssertReadJson(json); + } + + [Fact] + public void FieldMask_Root() + { + var json = @"""value1,value2,value3.nestedValue"""; + + AssertReadJson(json); + } + + [Fact] + public void NullableWrapper_Root_Int32() + { + var json = @"1"; + + AssertReadJson(json); + } + + [Fact] + public void NullableWrapper_Root_Int64() + { + var json = @"""1"""; + + AssertReadJson(json); + } + + private TValue AssertReadJson(string value, GrpcJsonSettings? settings = null) where TValue : IMessage, new() + { + var typeRegistery = TypeRegistry.FromFiles( + HelloRequest.Descriptor.File, + Timestamp.Descriptor.File); + + var formatter = new JsonParser(new JsonParser.Settings( + recursionLimit: int.MaxValue, + typeRegistery)); + + var objectOld = formatter.Parse(value); + + var jsonSerializerOptions = CreateSerializerOptions(settings, typeRegistery); + + var objectNew = JsonSerializer.Deserialize(value, jsonSerializerOptions)!; + + _output.WriteLine("New:"); + _output.WriteLine(objectNew.ToString()); + + _output.WriteLine("Old:"); + _output.WriteLine(objectOld.ToString()); + + Assert.True(objectNew.Equals(objectOld)); + + return objectNew; + } + + private void AssertReadJsonError(string value, Action assertException, GrpcJsonSettings? settings = null) where TValue : IMessage, new() + { + var typeRegistery = TypeRegistry.FromFiles( + HelloRequest.Descriptor.File, + Timestamp.Descriptor.File); + + var jsonSerializerOptions = CreateSerializerOptions(settings, typeRegistery); + + var ex = Assert.ThrowsAny(() => JsonSerializer.Deserialize(value, jsonSerializerOptions)); + assertException(ex); + + var formatter = new JsonParser(new JsonParser.Settings( + recursionLimit: int.MaxValue, + typeRegistery)); + + ex = Assert.ThrowsAny(() => formatter.Parse(value)); + assertException(ex); + } + + internal static JsonSerializerOptions CreateSerializerOptions(GrpcJsonSettings? settings, TypeRegistry? typeRegistery) + { + var context = new JsonContext(settings ?? new GrpcJsonSettings(), typeRegistery ?? TypeRegistry.Empty); + + return JsonConverterHelper.CreateSerializerOptions(context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterWriteTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterWriteTests.cs new file mode 100644 index 00000000000..bfec7a9793c --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterWriteTests.cs @@ -0,0 +1,495 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; +using Transcoding; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.ConverterTests; + +public class JsonConverterWriteTests +{ + private readonly ITestOutputHelper _output; + + public JsonConverterWriteTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void NonAsciiString() + { + var helloRequest = new HelloRequest + { + Name = "This is a test 激光這兩個字是甚麼意思 string" + }; + + AssertWrittenJson(helloRequest, compareRawStrings: true); + } + + [Fact] + public void RepeatedStrings() + { + var helloRequest = new HelloRequest + { + Name = "test", + RepeatedStrings = + { + "One", + "Two", + "Three" + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void RepeatedDoubleValues() + { + var helloRequest = new HelloRequest + { + RepeatedDoubleValues = + { + 1, + 1.1 + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void MapStrings() + { + var helloRequest = new HelloRequest + { + MapStrings = + { + ["name1"] = "value1", + ["name2"] = "value2" + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void MapKeyBool() + { + var helloRequest = new HelloRequest + { + MapKeybool = + { + [true] = "value1", + [false] = "value2" + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void MapKeyInt() + { + var helloRequest = new HelloRequest + { + MapKeyint = + { + [-1] = "value1", + [0] = "value2", + [0] = "value3" + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void MapMessages() + { + var helloRequest = new HelloRequest + { + MapMessage = + { + ["name1"] = new HelloRequest.Types.SubMessage { Subfield = "value1" }, + ["name2"] = new HelloRequest.Types.SubMessage { Subfield = "value2" } + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void DataTypes_DefaultValues() + { + var wrappers = new HelloRequest.Types.DataTypes(); + + AssertWrittenJson( + wrappers, + new GrpcJsonSettings { WriteInt64sAsStrings = true }); + } + + [Fact] + public void NullableWrappers_NaN() + { + var wrappers = new HelloRequest.Types.Wrappers + { + DoubleValue = double.NaN + }; + + AssertWrittenJson(wrappers); + } + + [Fact] + public void NullValue_Default() + { + var m = new NullValueContainer(); + + AssertWrittenJson(m); + } + + [Fact] + public void NullValue_NonDefaultValue() + { + var m = new NullValueContainer + { + NullValue = (NullValue)1 + }; + + AssertWrittenJson(m); + } + + [Fact] + public void NullableWrappers() + { + var wrappers = new HelloRequest.Types.Wrappers + { + BoolValue = true, + BytesValue = ByteString.CopyFrom(Encoding.UTF8.GetBytes("Hello world")), + DoubleValue = 1.1, + FloatValue = 1.2f, + Int32Value = 1, + Int64Value = 2L, + StringValue = "A string", + Uint32Value = 3U, + Uint64Value = 4UL + }; + + AssertWrittenJson(wrappers); + } + + [Fact] + public void NullableWrapper_Root_Int32() + { + var v = new Int32Value { Value = 1 }; + + AssertWrittenJson(v); + } + + [Fact] + public void NullableWrapper_Root_Int64() + { + var v = new Int64Value { Value = 1 }; + + AssertWrittenJson(v); + } + + [Theory] + [InlineData(true, @"""1""")] + [InlineData(false, @"1")] + public void NullableWrapper_Root_Int64_WriteAsStrings(bool writeInt64sAsStrings, string expectedJson) + { + var v = new Int64Value { Value = 1 }; + + var settings = new GrpcJsonSettings { WriteInt64sAsStrings = writeInt64sAsStrings }; + var jsonSerializerOptions = CreateSerializerOptions(settings, TypeRegistry.Empty); + var json = JsonSerializer.Serialize(v, jsonSerializerOptions); + + Assert.Equal(expectedJson, json); + } + + [Theory] + [InlineData(true, @"""2""")] + [InlineData(false, @"2")] + public void NullableWrapper_Root_UInt64_WriteAsStrings(bool writeInt64sAsStrings, string expectedJson) + { + var v = new UInt64Value { Value = 2 }; + + var settings = new GrpcJsonSettings { WriteInt64sAsStrings = writeInt64sAsStrings }; + var jsonSerializerOptions = CreateSerializerOptions(settings, TypeRegistry.Empty); + var json = JsonSerializer.Serialize(v, jsonSerializerOptions); + + Assert.Equal(expectedJson, json); + } + + [Fact] + public void Any() + { + var helloRequest = new HelloRequest + { + Name = "In any!" + }; + var any = Google.Protobuf.WellKnownTypes.Any.Pack(helloRequest); + + AssertWrittenJson(any); + } + + [Fact] + public void Any_WellKnownType_Timestamp() + { + var timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch); + var any = Google.Protobuf.WellKnownTypes.Any.Pack(timestamp); + + AssertWrittenJson(any); + } + + [Fact] + public void Any_WellKnownType_Int32() + { + var value = new Int32Value() { Value = int.MaxValue }; + var any = Google.Protobuf.WellKnownTypes.Any.Pack(value); + + AssertWrittenJson(any); + } + + [Fact] + public void Timestamp_Nested() + { + var helloRequest = new HelloRequest + { + TimestampValue = Timestamp.FromDateTimeOffset(new DateTimeOffset(2020, 12, 1, 12, 30, 0, TimeSpan.FromHours(12))) + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void Timestamp_Root() + { + var ts = Timestamp.FromDateTimeOffset(new DateTimeOffset(2020, 12, 1, 12, 30, 0, TimeSpan.FromHours(12))); + + AssertWrittenJson(ts); + } + + [Fact] + public void Duration_Nested() + { + var helloRequest = new HelloRequest + { + DurationValue = Duration.FromTimeSpan(TimeSpan.FromHours(12)) + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void Duration_Root() + { + var duration = Duration.FromTimeSpan(TimeSpan.FromHours(12)); + + AssertWrittenJson(duration); + } + + [Fact] + public void Value_Nested() + { + var helloRequest = new HelloRequest + { + ValueValue = Value.ForStruct(new Struct + { + Fields = + { + ["enabled"] = Value.ForBool(true), + ["metadata"] = Value.ForList( + Value.ForString("value1"), + Value.ForString("value2")) + } + }) + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void Value_Root() + { + var value = Value.ForStruct(new Struct + { + Fields = + { + ["enabled"] = Value.ForBool(true), + ["metadata"] = Value.ForList( + Value.ForString("value1"), + Value.ForString("value2")) + } + }); + + AssertWrittenJson(value); + } + + [Fact] + public void Struct_Nested() + { + var helloRequest = new HelloRequest + { + StructValue = new Struct + { + Fields = + { + ["enabled"] = Value.ForBool(true), + ["metadata"] = Value.ForList( + Value.ForString("value1"), + Value.ForString("value2")) + } + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void Struct_Root() + { + var value = new Struct + { + Fields = + { + ["enabled"] = Value.ForBool(true), + ["metadata"] = Value.ForList( + Value.ForString("value1"), + Value.ForString("value2")) + } + }; + + AssertWrittenJson(value); + } + + [Fact] + public void ListValue_Nested() + { + var helloRequest = new HelloRequest + { + ListValue = new ListValue + { + Values = + { + Value.ForBool(true), + Value.ForString("value1"), + Value.ForString("value2") + } + } + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void ListValue_Root() + { + var value = new ListValue + { + Values = + { + Value.ForBool(true), + Value.ForString("value1"), + Value.ForString("value2") + } + }; + + AssertWrittenJson(value); + } + + [Fact] + public void FieldMask_Nested() + { + var helloRequest = new HelloRequest + { + FieldMaskValue = FieldMask.FromString("value1,value2,value3.nested_value"), + }; + + AssertWrittenJson(helloRequest); + } + + [Fact] + public void FieldMask_Root() + { + var m = FieldMask.FromString("value1,value2,value3.nested_value"); + + AssertWrittenJson(m); + } + + [Theory] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Unspecified)] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Bar)] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Neg)] + [InlineData((HelloRequest.Types.DataTypes.Types.NestedEnum)100)] + public void Enum(HelloRequest.Types.DataTypes.Types.NestedEnum value) + { + var dataTypes = new HelloRequest.Types.DataTypes + { + SingleEnum = value + }; + + AssertWrittenJson(dataTypes); + } + + [Theory] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Unspecified)] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Bar)] + [InlineData(HelloRequest.Types.DataTypes.Types.NestedEnum.Neg)] + [InlineData((HelloRequest.Types.DataTypes.Types.NestedEnum)100)] + public void Enum_WriteNumber(HelloRequest.Types.DataTypes.Types.NestedEnum value) + { + var dataTypes = new HelloRequest.Types.DataTypes + { + SingleEnum = value + }; + + AssertWrittenJson(dataTypes, new GrpcJsonSettings { WriteEnumsAsIntegers = true, IgnoreDefaultValues = true }); + } + + private void AssertWrittenJson(TValue value, GrpcJsonSettings? settings = null, bool? compareRawStrings = null) where TValue : IMessage + { + var typeRegistery = TypeRegistry.FromFiles( + HelloRequest.Descriptor.File, + Timestamp.Descriptor.File); + + settings ??= new GrpcJsonSettings { WriteInt64sAsStrings = true }; + + var formatterSettings = new JsonFormatter.Settings( + formatDefaultValues: !settings.IgnoreDefaultValues, + typeRegistery); + formatterSettings = formatterSettings.WithFormatEnumsAsIntegers(settings.WriteEnumsAsIntegers); + var formatter = new JsonFormatter(formatterSettings); + + var jsonOld = formatter.Format(value); + + _output.WriteLine("Old:"); + _output.WriteLine(jsonOld); + + var jsonSerializerOptions = CreateSerializerOptions(settings, typeRegistery); + var jsonNew = JsonSerializer.Serialize(value, jsonSerializerOptions); + + _output.WriteLine("New:"); + _output.WriteLine(jsonNew); + + using var doc1 = JsonDocument.Parse(jsonNew); + using var doc2 = JsonDocument.Parse(jsonOld); + + var comparer = new JsonElementComparer(maxHashDepth: -1, compareRawStrings: compareRawStrings ?? false); + Assert.True(comparer.Equals(doc1.RootElement, doc2.RootElement)); + } + + internal static JsonSerializerOptions CreateSerializerOptions(GrpcJsonSettings? settings, TypeRegistry? typeRegistery) + { + var context = new JsonContext(settings ?? new GrpcJsonSettings(), typeRegistery ?? TypeRegistry.Empty); + + return JsonConverterHelper.CreateSerializerOptions(context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonElementComparer.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonElementComparer.cs new file mode 100644 index 00000000000..c220f094328 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonElementComparer.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.ConverterTests; + +public class JsonElementComparer : IEqualityComparer +{ + public JsonElementComparer() : this(maxHashDepth: -1, compareRawStrings: false) { } + + public JsonElementComparer(int maxHashDepth, bool compareRawStrings) + { + MaxHashDepth = maxHashDepth; + CompareRawStrings = compareRawStrings; + } + + private int MaxHashDepth { get; } + private bool CompareRawStrings { get; } + + #region IEqualityComparer Members + + public bool Equals(JsonElement x, JsonElement y) + { + if (x.ValueKind != y.ValueKind) + { + return false; + } + + switch (x.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Undefined: + return true; + + // Compare the raw values of numbers, and the text of strings. + // Note this means that 0.0 will differ from 0.00 -- which may be correct as deserializing either to `decimal` will result in subtly different results. + // Newtonsoft's JValue.Compare(JTokenType valueType, object? objA, object? objB) has logic for detecting "equivalent" values, + // you may want to examine it to see if anything there is required here. + // https://github.com/JamesNK/Newtonsoft.Json/blob/master/Src/Newtonsoft.Json/Linq/JValue.cs#L246 + case JsonValueKind.Number: + return x.GetRawText() == y.GetRawText(); + + case JsonValueKind.String: + if (CompareRawStrings) + { + return x.GetRawText() == y.GetRawText(); + } + else + { + // Automatically resolve JSON escape sequences to their corresponding characters. + return x.GetString() == y.GetString(); + } + + case JsonValueKind.Array: + return x.EnumerateArray().SequenceEqual(y.EnumerateArray(), this); + + case JsonValueKind.Object: + { + // Surprisingly, JsonDocument fully supports duplicate property names. + // I.e. it's perfectly happy to parse {"Value":"a", "Value" : "b"} and will store both + // key/value pairs inside the document! + // A close reading of https://www.rfc-editor.org/rfc/rfc8259#section-4 seems to indicate that + // such objects are allowed but not recommended, and when they arise, interpretation of + // identically-named properties is order-dependent. + // So stably sorting by name then comparing values seems the way to go. + var xPropertiesUnsorted = x.EnumerateObject().ToList(); + var yPropertiesUnsorted = y.EnumerateObject().ToList(); + if (xPropertiesUnsorted.Count != yPropertiesUnsorted.Count) + { + return false; + } + + var xProperties = xPropertiesUnsorted.OrderBy(p => p.Name, StringComparer.Ordinal); + var yProperties = yPropertiesUnsorted.OrderBy(p => p.Name, StringComparer.Ordinal); + foreach (var (px, py) in xProperties.Zip(yProperties)) + { + if (px.Name != py.Name) + { + return false; + } + + if (!Equals(px.Value, py.Value)) + { + return false; + } + } + return true; + } + + default: + throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Unknown JsonValueKind {0}", x.ValueKind)); + } + } + + public int GetHashCode(JsonElement obj) + { + var hash = new HashCode(); // New in .Net core: https://docs.microsoft.com/en-us/dotnet/api/system.hashcode + ComputeHashCode(obj, ref hash, 0); + return hash.ToHashCode(); + } + + void ComputeHashCode(JsonElement obj, ref HashCode hash, int depth) + { + hash.Add(obj.ValueKind); + + switch (obj.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Undefined: + break; + + case JsonValueKind.Number: + hash.Add(obj.GetRawText()); + break; + + case JsonValueKind.String: + hash.Add(obj.GetString()); + break; + + case JsonValueKind.Array: + if (depth != MaxHashDepth) + { + foreach (var item in obj.EnumerateArray()) + { + ComputeHashCode(item, ref hash, depth + 1); + } + } + else + { + hash.Add(obj.GetArrayLength()); + } + + break; + + case JsonValueKind.Object: + foreach (var property in obj.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) + { + hash.Add(property.Name); + if (depth != MaxHashDepth) + { + ComputeHashCode(property.Value, ref hash, depth + 1); + } + } + break; + + default: + throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Unknown JsonValueKind {0}", obj.ValueKind)); + } + } + + #endregion +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/GrpcHttpApiServiceExtensionsTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/GrpcHttpApiServiceExtensionsTests.cs new file mode 100644 index 00000000000..f0b8ddab58a --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/GrpcHttpApiServiceExtensionsTests.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class GrpcJsonTranscodingServiceExtensionsTests +{ + [Fact] + public void AddGrpcJsonTranscoding_DefaultOptions_PopulatedProperties() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddGrpc().AddJsonTranscoding(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options1 = serviceProvider.GetRequiredService>().Value; + + Assert.NotNull(options1.JsonSettings); + + var options2 = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(options1, options2); + } + + [Fact] + public void AddGrpcJsonTranscoding_OverrideOptions_OptionsApplied() + { + // Arrange + var settings = new GrpcJsonSettings(); + + var services = new ServiceCollection(); + + // Act + services.AddGrpc().AddJsonTranscoding(o => + { + o.JsonSettings = settings; + }); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(settings, options.JsonSettings); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServerCallContextTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServerCallContextTests.cs new file mode 100644 index 00000000000..2e3d6def028 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServerCallContextTests.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.Core; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using MethodOptions = Grpc.Shared.Server.MethodOptions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class JsonTranscodingServerCallContextTests +{ + [Fact] + public void CancellationToken_Get_MatchHttpContextRequestAborted() + { + // Arrange + var cts = new CancellationTokenSource(); + var httpContext = CreateHttpContext(cancellationToken: cts.Token); + var serverCallContext = CreateServerCallContext(httpContext); + + // Act + var ct = serverCallContext.CancellationToken; + + // Assert + Assert.Equal(cts.Token, ct); + } + + [Fact] + public void RequestHeaders_Get_PopulatedFromHttpContext() + { + // Arrange + var httpContext = CreateHttpContext(); + httpContext.Request.Headers.Add("TestName", "TestValue"); + httpContext.Request.Headers.Add(":method", "GET"); + httpContext.Request.Headers.Add("grpc-encoding", "identity"); + httpContext.Request.Headers.Add("grpc-timeout", "1S"); + httpContext.Request.Headers.Add("hello-bin", Convert.ToBase64String(new byte[] { 1, 2, 3 })); + var serverCallContext = CreateServerCallContext(httpContext); + + // Act + var headers = serverCallContext.RequestHeaders; + + // Assert + Assert.Equal(2, headers.Count); + Assert.Equal("testname", headers[0].Key); + Assert.Equal("TestValue", headers[0].Value); + Assert.Equal("hello-bin", headers[1].Key); + Assert.True(headers[1].IsBinary); + Assert.Equal(new byte[] { 1, 2, 3 }, headers[1].ValueBytes); + } + + private static DefaultHttpContext CreateHttpContext(CancellationToken cancellationToken = default) + { + var serviceCollection = new ServiceCollection(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("localhost"); + httpContext.RequestServices = serviceProvider; + httpContext.Response.Body = new MemoryStream(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1"); + httpContext.Features.Set(new HttpRequestLifetimeFeature(cancellationToken)); + return httpContext; + } + + private class HttpRequestLifetimeFeature : IHttpRequestLifetimeFeature + { + public HttpRequestLifetimeFeature(CancellationToken cancellationToken) + { + RequestAborted = cancellationToken; + } + + public CancellationToken RequestAborted { get; set; } + + public void Abort() + { + } + } + + private static JsonTranscodingServerCallContext CreateServerCallContext(DefaultHttpContext httpContext) + { + return new JsonTranscodingServerCallContext( + httpContext, + MethodOptions.Create(Enumerable.Empty()), + new Method( + MethodType.Unary, + "Server", + "Method", + new Marshaller(o => null!, c => null!), + new Marshaller(o => null!, c => null!)), + new CallHandlerDescriptorInfo( + null, + null, + false, + null, + new Dictionary>()), + NullLogger.Instance); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServiceMethodProviderTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServiceMethodProviderTests.cs new file mode 100644 index 00000000000..470db8b06db --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpApiServiceMethodProviderTests.cs @@ -0,0 +1,235 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Binding; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class JsonTranscodingServiceMethodProviderTests +{ + [Fact] + public void AddMethod_OptionGet_ResolveMethod() + { + // Arrange & Act + var endpoints = MapEndpoints(); + + // Assert + var endpoint = FindGrpcEndpoint(endpoints, nameof(JsonTranscodingGreeterService.SayHello)); + + Assert.Equal("GET", endpoint.Metadata.GetMetadata()?.HttpMethods.Single()); + Assert.Equal("/v1/greeter/{name}", endpoint.RoutePattern.RawText); + Assert.Equal(1, endpoint.RoutePattern.Parameters.Count); + Assert.Equal("name", endpoint.RoutePattern.Parameters[0].Name); + } + + [Fact] + public void AddMethod_OptionCustom_ResolveMethod() + { + // Arrange & Act + var endpoints = MapEndpoints(); + + // Assert + var endpoint = FindGrpcEndpoint(endpoints, nameof(JsonTranscodingGreeterService.Custom)); + + Assert.Equal("/v1/greeter/{name}", endpoint.RoutePattern.RawText); + Assert.Equal("HEAD", endpoint.Metadata.GetMetadata()?.HttpMethods.Single()); + } + + [Fact] + public void AddMethod_OptionAdditionalBindings_ResolveMethods() + { + // Arrange & Act + var endpoints = MapEndpoints(); + + var matchedEndpoints = FindGrpcEndpoints(endpoints, nameof(JsonTranscodingGreeterService.AdditionalBindings)); + + // Assert + Assert.Equal(2, matchedEndpoints.Count); + + var getMethodModel = matchedEndpoints[0]; + Assert.Equal("GET", getMethodModel.Metadata.GetMetadata()?.HttpMethods.Single()); + Assert.Equal("/v1/additional_bindings/{name}", getMethodModel.Metadata.GetMetadata()?.HttpRule.Get); + Assert.Equal("/v1/additional_bindings/{name}", getMethodModel.RoutePattern.RawText); + + var additionalMethodModel = matchedEndpoints[1]; + Assert.Equal("DELETE", additionalMethodModel.Metadata.GetMetadata()?.HttpMethods.Single()); + Assert.Equal("/v1/additional_bindings/{name}", additionalMethodModel.Metadata.GetMetadata()?.HttpRule.Delete); + Assert.Equal("/v1/additional_bindings/{name}", additionalMethodModel.RoutePattern.RawText); + } + + [Fact] + public void AddMethod_NoHttpRuleInProto_ThrowNotFoundError() + { + // Arrange & Act + var endpoints = MapEndpoints(); + + // Assert + var ex = Assert.Throws(() => FindGrpcEndpoint(endpoints, nameof(JsonTranscodingGreeterService.NoOption))); + Assert.Equal("Couldn't find gRPC endpoint for method NoOption.", ex.Message); + } + + [Fact] + public void AddMethod_Success_HttpRuleFoundLogged() + { + // Arrange + var testSink = new TestSink(); + var testProvider = new TestLoggerProvider(testSink); + + // Act + var endpoints = MapEndpoints( + configureLogging: c => + { + c.AddProvider(testProvider); + c.SetMinimumLevel(LogLevel.Trace); + }); + + // Assert + var write = testSink.Writes.Single(w => + { + if (w.EventId.Name != "HttpRuleFound") + { + return false; + } + var values = (IReadOnlyList>)w.State; + if ((string)values.Single(v => v.Key == "MethodName").Value! != "SayHello") + { + return false; + } + + return true; + }); + + Assert.Equal(@"Found HttpRule mapping. Method SayHello on transcoding.JsonTranscodingGreeter. HttpRule payload: { ""get"": ""/v1/greeter/{name}"" }", write.Message); + } + + [Fact] + public void AddMethod_StreamingMethods_ThrowNotFoundError() + { + // Arrange + var testSink = new TestSink(); + var testProvider = new TestLoggerProvider(testSink); + + // Act + var endpoints = MapEndpoints( + configureLogging: c => + { + c.AddProvider(testProvider); + c.SetMinimumLevel(LogLevel.Trace); + }); + + // Assert + Assert.Contains(testSink.Writes, c => c.Message == "Unable to bind GetClientStreaming on transcoding.JsonTranscodingStreaming to gRPC JSON transcoding. Client and bidirectional streaming methods are not supported."); + Assert.Contains(testSink.Writes, c => c.Message == "Unable to bind GetBidiStreaming on transcoding.JsonTranscodingStreaming to gRPC JSON transcoding. Client and bidirectional streaming methods are not supported."); + + var matchedEndpoints = FindGrpcEndpoints(endpoints, nameof(JsonTranscodingStreamingService.GetServerStreaming)); + var endpoint = Assert.Single(matchedEndpoints); + + Assert.Equal("GET", endpoint.Metadata.GetMetadata()?.HttpMethods.Single()); + Assert.Equal("/v1/server_greeter/{name}", endpoint.Metadata.GetMetadata()?.HttpRule.Get); + Assert.Equal("/v1/server_greeter/{name}", endpoint.RoutePattern.RawText); + } + + [Fact] + public void AddMethod_BadResponseBody_ThrowError() + { + // Arrange & Act + var ex = Assert.Throws(() => MapEndpoints()); + + // Assert + Assert.Equal("Error binding gRPC service 'JsonTranscodingInvalidResponseBodyGreeterService'.", ex.Message); + Assert.Equal("Error binding BadResponseBody on JsonTranscodingInvalidResponseBodyGreeterService to HTTP API.", ex.InnerException!.InnerException!.Message); + Assert.Equal("Couldn't find matching field for response body 'NoMatch' on HelloReply.", ex.InnerException!.InnerException!.InnerException!.Message); + } + + [Fact] + public void AddMethod_BadBody_ThrowError() + { + // Arrange & Act + var ex = Assert.Throws(() => MapEndpoints()); + + // Assert + Assert.Equal("Error binding gRPC service 'JsonTranscodingInvalidBodyGreeterService'.", ex.Message); + Assert.Equal("Error binding BadBody on JsonTranscodingInvalidBodyGreeterService to HTTP API.", ex.InnerException!.InnerException!.Message); + Assert.Equal("Couldn't find matching field for body 'NoMatch' on HelloRequest.", ex.InnerException!.InnerException!.InnerException!.Message); + } + + [Fact] + public void AddMethod_BadPattern_ThrowError() + { + // Arrange & Act + var ex = Assert.Throws(() => MapEndpoints()); + + // Assert + Assert.Equal("Error binding gRPC service 'JsonTranscodingInvalidPatternGreeterService'.", ex.Message); + Assert.Equal("Error binding BadPattern on JsonTranscodingInvalidPatternGreeterService to HTTP API.", ex.InnerException!.InnerException!.Message); + Assert.Equal("Path template 'v1/greeter/{name}' must start with a '/'.", ex.InnerException!.InnerException!.InnerException!.Message); + } + + private static RouteEndpoint FindGrpcEndpoint(IReadOnlyList endpoints, string methodName) + { + var e = FindGrpcEndpoints(endpoints, methodName).SingleOrDefault(); + if (e == null) + { + throw new InvalidOperationException($"Couldn't find gRPC endpoint for method {methodName}."); + } + + return e; + } + + private static List FindGrpcEndpoints(IReadOnlyList endpoints, string methodName) + { + var e = endpoints + .Where(e => e.Metadata.GetMetadata()?.Method.Name == methodName) + .Cast() + .ToList(); + + return e; + } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public ICollection DataSources { get; } + public IServiceProvider ServiceProvider { get; } + + public TestEndpointRouteBuilder(IServiceProvider serviceProvider) + { + DataSources = new List(); + ServiceProvider = serviceProvider; + } + + public IApplicationBuilder CreateApplicationBuilder() + { + return new ApplicationBuilder(ServiceProvider); + } + } + + private IReadOnlyList MapEndpoints(Action? configureLogging = null) + where TService : class + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(log => + { + configureLogging?.Invoke(log); + }); + serviceCollection.AddGrpc(); + serviceCollection.RemoveAll(typeof(IServiceMethodProvider<>)); + serviceCollection.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IServiceMethodProvider<>), typeof(JsonTranscodingServiceMethodProvider<>))); + + IEndpointRouteBuilder endpointRouteBuilder = new TestEndpointRouteBuilder(serviceCollection.BuildServiceProvider()); + + endpointRouteBuilder.MapGrpcService(); + + return endpointRouteBuilder.DataSources.Single().Endpoints; + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/HttpApiGreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/HttpApiGreeterService.cs new file mode 100644 index 00000000000..e0e3e9195b7 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/HttpApiGreeterService.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.Core; +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +public class JsonTranscodingGreeterService : JsonTranscodingGreeter.JsonTranscodingGreeterBase +{ + public override Task SayHello(HelloRequest request, ServerCallContext context) + { + return base.SayHello(request, context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/SyncPoint.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/SyncPoint.cs new file mode 100644 index 00000000000..653069df081 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/SyncPoint.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +public class SyncPoint +{ + private readonly TaskCompletionSource _atSyncPoint; + private readonly TaskCompletionSource _continueFromSyncPoint; + + public SyncPoint(bool runContinuationsAsynchronously = true) + { + var taskCreationOptions = runContinuationsAsynchronously ? TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None; + + _atSyncPoint = new TaskCompletionSource(taskCreationOptions); + _continueFromSyncPoint = new TaskCompletionSource(taskCreationOptions); + } + + /// + /// Waits for the code-under-test to reach . + /// + /// + public Task WaitForSyncPoint() => _atSyncPoint.Task; + + /// + /// Cancel waiting for the code-under-test to reach . + /// + /// A cancellation token. + public void CancelWaitForSyncPoint(CancellationToken cancellationToken) => _atSyncPoint.TrySetCanceled(cancellationToken); + + /// + /// Releases the code-under-test to continue past where it waited for . + /// + public void Continue() => _continueFromSyncPoint.TrySetResult(null); + + /// + /// Used by the code-under-test to wait for the test code to sync up. + /// + /// + /// This code will unblock and then block waiting for to be called. + /// + /// + public Task WaitToContinue() + { + _atSyncPoint.TrySetResult(null); + return _continueFromSyncPoint.Task; + } + + public static Func Create(out SyncPoint syncPoint, bool runContinuationsAsynchronously = true) + { + var handler = Create(1, out var syncPoints, runContinuationsAsynchronously); + syncPoint = syncPoints[0]; + return handler; + } + + /// + /// Creates a re-entrant function that waits for sync points in sequence. + /// + /// The number of sync points to expect + /// The objects that can be used to coordinate the sync point + /// + public static Func Create(int count, out SyncPoint[] syncPoints, bool runContinuationsAsynchronously = true) + { + // Need to use a local so the closure can capture it. You can't use out vars in a closure. + var localSyncPoints = new SyncPoint[count]; + for (var i = 0; i < count; i += 1) + { + localSyncPoints[i] = new SyncPoint(runContinuationsAsynchronously); + } + + syncPoints = localSyncPoints; + + var counter = 0; + return () => + { + if (counter >= localSyncPoints.Length) + { + return Task.CompletedTask; + } + else + { + var syncPoint = localSyncPoints[counter]; + + counter += 1; + return syncPoint.WaitToContinue(); + } + }; + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs new file mode 100644 index 00000000000..03a26331d65 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.Core.Interceptors; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; + +internal static class TestHelpers +{ + public static DefaultHttpContext CreateHttpContext(CancellationToken cancellationToken = default, Stream? bodyStream = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(typeof(IGrpcInterceptorActivator<>), typeof(TestInterceptorActivator<>)); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("localhost"); + httpContext.RequestServices = serviceProvider; + httpContext.Response.Body = bodyStream ?? new MemoryStream(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1"); + httpContext.Features.Set(new HttpRequestLifetimeFeature(cancellationToken)); + return httpContext; + } + + private class TestInterceptorActivator : IGrpcInterceptorActivator where T : Interceptor + { + public GrpcActivatorHandle Create(IServiceProvider serviceProvider, InterceptorRegistration interceptorRegistration) + { + return new GrpcActivatorHandle(Activator.CreateInstance(), created: true, state: null); + } + + public ValueTask ReleaseAsync(GrpcActivatorHandle interceptor) + { + return default; + } + } + + private class HttpRequestLifetimeFeature : IHttpRequestLifetimeFeature + { + public HttpRequestLifetimeFeature(CancellationToken cancellationToken) + { + RequestAborted = cancellationToken; + } + + public CancellationToken RequestAborted { get; set; } + + public void Abort() + { + } + } + + public static CallHandlerDescriptorInfo CreateDescriptorInfo( + FieldDescriptor? responseBodyDescriptor = null, + Dictionary>? routeParameterDescriptors = null, + MessageDescriptor? bodyDescriptor = null, + bool? bodyDescriptorRepeated = null, + List? bodyFieldDescriptors = null) + { + return new CallHandlerDescriptorInfo( + responseBodyDescriptor, + bodyDescriptor, + bodyDescriptorRepeated ?? false, + bodyFieldDescriptors, + routeParameterDescriptors ?? new Dictionary>()); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj new file mode 100644 index 00000000000..3f6170a9b41 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.csproj @@ -0,0 +1,14 @@ + + + $(DefaultNetCoreTargetFramework) + enable + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/transcoding.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/transcoding.proto new file mode 100644 index 00000000000..bdab9232df8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/transcoding.proto @@ -0,0 +1,194 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/field_mask.proto"; + +package transcoding; + +service JsonTranscodingGreeter { + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + rpc ResponseBody (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + response_body: "message" + }; + } + rpc Custom (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + custom: { + kind: "HEAD", + path: "/v1/greeter/{name}" + } + }; + } + rpc AdditionalBindings (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/additional_bindings/{name}" + additional_bindings { + delete: "/v1/additional_bindings/{name}" + } + }; + } + rpc NoOption (HelloRequest) returns (HelloReply); + rpc ServerStreamingGetOption (HelloRequest) returns (stream HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + rpc Body (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "*" + }; + } + rpc SubBody (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "sub" + }; + } + rpc SubRepeatedBody (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "repeated_strings" + }; + } +} + +service JsonTranscodingInvalidResponseBodyGreeter { + rpc BadResponseBody (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + response_body: "NoMatch" + }; + } +} + +service JsonTranscodingInvalidBodyGreeter { + rpc BadBody (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + body: "NoMatch" + }; + } +} + +service JsonTranscodingInvalidPatternGreeter { + rpc BadPattern (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "v1/greeter/{name}" + body: "NoMatch" + }; + } +} + +service JsonTranscodingStreaming { + rpc GetServerStreaming (HelloRequest) returns (stream HelloReply) { + option (google.api.http) = { + get: "/v1/server_greeter/{name}" + }; + } + rpc GetClientStreaming (stream HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/client_greeter/{name}" + }; + } + rpc GetBidiStreaming (stream HelloRequest) returns (stream HelloReply) { + option (google.api.http) = { + get: "/v1/bidi_greeter/{name}" + }; + } +} + +message HelloRequest { + message SubMessage { + string subfield = 1; + repeated string subfields = 2; + } + message DataTypes { + enum NestedEnum { + NESTED_ENUM_UNSPECIFIED = 0; + FOO = 1; + BAR = 2; + BAZ = 3; + NEG = -1; // Intentionally negative. + } + message NestedMessage { + string subfield = 1; + } + int32 single_int32 = 1; + int64 single_int64 = 2; + uint32 single_uint32 = 3; + uint64 single_uint64 = 4; + sint32 single_sint32 = 5; + sint64 single_sint64 = 6; + fixed32 single_fixed32 = 7; + fixed64 single_fixed64 = 8; + sfixed32 single_sfixed32 = 9; + sfixed64 single_sfixed64 = 10; + float single_float = 11; + double single_double = 12; + bool single_bool = 13; + string single_string = 14; + bytes single_bytes = 15; + NestedEnum single_enum = 16; + NestedMessage single_message = 17; + } + message Wrappers { + google.protobuf.StringValue string_value = 1; + google.protobuf.Int32Value int32_value = 2; + google.protobuf.Int64Value int64_value = 3; + google.protobuf.FloatValue float_value = 4; + google.protobuf.DoubleValue double_value = 5; + google.protobuf.BoolValue bool_value = 6; + google.protobuf.UInt32Value uint32_value = 7; + google.protobuf.UInt64Value uint64_value = 8; + google.protobuf.BytesValue bytes_value = 9; + } + string name = 1; + SubMessage sub = 2; + DataTypes data = 3; + Wrappers wrappers = 4; + repeated string repeated_strings = 5; + google.protobuf.Any any_message = 6; + map map_strings = 7; + map map_message = 8; + map map_keybool = 9; + map map_keyint = 10; + oneof oneof_test { + string oneof_name1 = 11; + string oneof_name2 = 12; + } + int32 age = 13; + repeated google.protobuf.DoubleValue repeated_double_values = 14; + google.protobuf.Timestamp timestamp_value = 15; + google.protobuf.Duration duration_value = 16; + google.protobuf.Value value_value = 17; + google.protobuf.Struct struct_value = 18; + google.protobuf.ListValue list_value = 19; + google.protobuf.NullValue null_value = 20; + google.protobuf.FieldMask field_mask_value = 21; +} + +message HelloReply { + string message = 1; + repeated string values = 2; + google.protobuf.StringValue nullable_message = 3; + google.protobuf.Any any_message = 4; +} + +message NullValueContainer { + google.protobuf.NullValue null_value = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs new file mode 100644 index 00000000000..9e0ed1a9b5e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs @@ -0,0 +1,229 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Grpc.Shared.Server; +using Grpc.Tests.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; +using Microsoft.AspNetCore.Testing; +using Transcoding; +using Xunit.Abstractions; +using MethodOptions = Grpc.Shared.Server.MethodOptions; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class ServerStreamingServerCallHandlerTests : LoggedTest +{ + public ServerStreamingServerCallHandlerTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task HandleCallAsync_WriteMultipleMessages_Returned() + { + // Arrange + var syncPoint = new SyncPoint(); + + ServerStreamingServerMethod invoker = async (s, r, w, c) => + { + await w.WriteAsync(new HelloReply { Message = $"Hello {r.Name} 1" }); + await syncPoint.WaitToContinue(); + await w.WriteAsync(new HelloReply { Message = $"Hello {r.Name} 2" }); + }; + + var pipe = new Pipe(); + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors); + var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(bodyStream: pipe.Writer.AsStream()); + httpContext.Request.RouteValues["name"] = "TestName!"; + + // Act + var callTask = callHandler.HandleCallAsync(httpContext); + + // Assert + var line1 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + using var responseJson1 = JsonDocument.Parse(line1!); + Assert.Equal("Hello TestName! 1", responseJson1.RootElement.GetProperty("message").GetString()); + + await syncPoint.WaitForSyncPoint().DefaultTimeout(); + syncPoint.Continue(); + + var line2 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + using var responseJson2 = JsonDocument.Parse(line2!); + Assert.Equal("Hello TestName! 2", responseJson2.RootElement.GetProperty("message").GetString()); + + await callTask.DefaultTimeout(); + } + + [Fact] + public async Task HandleCallAsync_MessageThenError_MessageThenErrorReturned() + { + // Arrange + ServerStreamingServerMethod invoker = async (s, r, w, c) => + { + await w.WriteAsync(new HelloReply { Message = $"Hello {r.Name} 1" }); + throw new Exception("Exception!"); + }; + + var pipe = new Pipe(); + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors); + var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(bodyStream: pipe.Writer.AsStream()); + httpContext.Request.RouteValues["name"] = "TestName!"; + + // Act + var callTask = callHandler.HandleCallAsync(httpContext); + + // Assert + var line1 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + using var responseJson1 = JsonDocument.Parse(line1!); + Assert.Equal("Hello TestName! 1", responseJson1.RootElement.GetProperty("message").GetString()); + + var line2 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + using var responseJson2 = JsonDocument.Parse(line2!); + Assert.Equal("Exception was thrown by handler.", responseJson2.RootElement.GetProperty("message").GetString()); + Assert.Equal("Exception was thrown by handler.", responseJson2.RootElement.GetProperty("error").GetString()); + Assert.Equal(2, responseJson2.RootElement.GetProperty("code").GetInt32()); + + await callTask.DefaultTimeout(); + } + + [Fact] + public async Task HandleCallAsync_ErrorWithDetailedErrors_DetailedErrorResponse() + { + // Arrange + ServerStreamingServerMethod invoker = (s, r, w, c) => + { + return Task.FromException(new Exception("Exception!")); + }; + + var pipe = new Pipe(); + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors); + var serviceOptions = new GrpcServiceOptions { EnableDetailedErrors = true }; + var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo, serviceOptions: serviceOptions); + var httpContext = TestHelpers.CreateHttpContext(bodyStream: pipe.Writer.AsStream()); + httpContext.Request.RouteValues["name"] = "TestName!"; + + // Act + var callTask = callHandler.HandleCallAsync(httpContext); + + // Assert + var line = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + using var responseJson = JsonDocument.Parse(line!); + Assert.Equal("Exception was thrown by handler. Exception: Exception!", responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal("Exception was thrown by handler. Exception: Exception!", responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal(2, responseJson.RootElement.GetProperty("code").GetInt32()); + + await callTask.DefaultTimeout(); + } + + public async Task ReadLineAsync(PipeReader pipeReader) + { + string? str; + + while (true) + { + var result = await pipeReader.ReadAsync(); + var buffer = result.Buffer; + + if ((str = ReadLine(ref buffer, out var end)) is not null) + { + pipeReader.AdvanceTo(end, end); + return str; + } + + pipeReader.AdvanceTo(buffer.Start, buffer.End); + + if (result.IsCompleted) + { + break; + } + } + + return str; + } + + private static string? ReadLine(ref ReadOnlySequence buffer, out SequencePosition end) + { + var reader = new SequenceReader(buffer); + + if (reader.TryReadTo(out ReadOnlySequence line, (byte)'\n')) + { + buffer = buffer.Slice(reader.Position); + end = reader.Position; + + return Encoding.UTF8.GetString(line); + } + + end = default; + return null; + } + + private ServerStreamingServerCallHandler CreateCallHandler( + ServerStreamingServerMethod invoker, + CallHandlerDescriptorInfo? descriptorInfo = null, + List<(Type Type, object[] Args)>? interceptors = null, + GrpcJsonTranscodingOptions? JsonTranscodingOptions = null, + GrpcServiceOptions? serviceOptions = null) + { + serviceOptions ??= new GrpcServiceOptions(); + if (interceptors != null) + { + foreach (var interceptor in interceptors) + { + serviceOptions.Interceptors.Add(interceptor.Type, interceptor.Args ?? Array.Empty()); + } + } + + var callInvoker = new ServerStreamingServerMethodInvoker( + invoker, + CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + MethodOptions.Create(new[] { serviceOptions }), + new TestGrpcServiceActivator()); + + var jsonSettings = JsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings() { WriteIndented = false }; + var jsonContext = new JsonContext(jsonSettings, JsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); + + return new ServerStreamingServerCallHandler( + callInvoker, + LoggerFactory, + descriptorInfo ?? TestHelpers.CreateDescriptorInfo(), + JsonConverterHelper.CreateSerializerOptions(jsonContext)); + } + + public static Marshaller GetMarshaller(MessageParser parser) where TMessage : IMessage => + Marshallers.Create(r => r.ToByteArray(), data => parser.ParseFrom(data)); + + public static readonly Method ServiceMethod = CreateServiceMethod("MethodName", HelloRequest.Parser, HelloReply.Parser); + + public static Method CreateServiceMethod(string methodName, MessageParser requestParser, MessageParser responseParser) + where TRequest : IMessage + where TResponse : IMessage + { + return new Method(MethodType.Unary, "ServiceName", methodName, GetMarshaller(requestParser), GetMarshaller(responseParser)); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiGreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiGreeterService.cs new file mode 100644 index 00000000000..203246b77ed --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiGreeterService.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class JsonTranscodingGreeterService : JsonTranscodingGreeter.JsonTranscodingGreeterBase +{ +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidBodyGreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidBodyGreeterService.cs new file mode 100644 index 00000000000..29ce019b5fd --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidBodyGreeterService.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class JsonTranscodingInvalidBodyGreeterService : JsonTranscodingInvalidBodyGreeter.JsonTranscodingInvalidBodyGreeterBase +{ +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidPatternGreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidPatternGreeterService.cs new file mode 100644 index 00000000000..a081b8fe9e2 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidPatternGreeterService.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class JsonTranscodingInvalidPatternGreeterService : JsonTranscodingInvalidPatternGreeter.JsonTranscodingInvalidPatternGreeterBase +{ +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidResponseBodyGreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidResponseBodyGreeterService.cs new file mode 100644 index 00000000000..6cbe3d728dc --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiInvalidResponseBodyGreeterService.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class JsonTranscodingInvalidResponseBodyGreeterService : JsonTranscodingInvalidResponseBodyGreeter.JsonTranscodingInvalidResponseBodyGreeterBase +{ +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiStreamingService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiStreamingService.cs new file mode 100644 index 00000000000..e32b19e8a21 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpApiStreamingService.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class JsonTranscodingStreamingService : JsonTranscodingStreaming.JsonTranscodingStreamingBase +{ +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs new file mode 100644 index 00000000000..eeaccf44299 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs @@ -0,0 +1,966 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Json; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server.Model; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Grpc.Shared; +using Grpc.Shared.Server; +using Grpc.Tests.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Primitives; +using Transcoding; +using Xunit.Abstractions; +using MethodOptions = Grpc.Shared.Server.MethodOptions; +using Type = System.Type; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class UnaryServerCallHandlerTests : LoggedTest +{ + public UnaryServerCallHandlerTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task HandleCallAsync_MatchingRouteValue_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }), + ["sub.subfield"] = new List(new[] + { + HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.SubFieldNumber), + HelloRequest.Types.SubMessage.Descriptor.FindFieldByNumber(HelloRequest.Types.SubMessage.SubfieldFieldNumber) + }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors); + var unaryServerCallHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.RouteValues["name"] = "TestName!"; + httpContext.Request.RouteValues["sub.subfield"] = "Subfield!"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("TestName!", request!.Name); + Assert.Equal("Subfield!", request!.Sub.Subfield); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("Hello TestName!", responseJson.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData("TestName!")] + [InlineData("")] + public async Task HandleCallAsync_ResponseBodySet_ResponseReturned(string name) + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = r.Name }); + }; + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + responseBodyDescriptor: HelloReply.Descriptor.FindFieldByNumber(HelloReply.MessageFieldNumber), + routeParameterDescriptors: routeParameterDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.RouteValues["name"] = name; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal(name, request!.Name); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(name, responseJson.RootElement.GetString()); + } + + [Fact] + public async Task HandleCallAsync_NullProperty_ResponseReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HelloReply { NullableMessage = null }); + }; + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + responseBodyDescriptor: HelloReply.Descriptor.FindFieldByNumber(HelloReply.NullableMessageFieldNumber), + routeParameterDescriptors: routeParameterDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.RouteValues["name"] = "Doesn't matter"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var sr = new StreamReader(httpContext.Response.Body); + var content = sr.ReadToEnd(); + + Assert.Equal("null", content); + } + + [Fact] + public async Task HandleCallAsync_ResponseBodySetToRepeatedField_ArrayReturned() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Values = { "One", "Two", "" } }); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: TestHelpers.CreateDescriptorInfo(responseBodyDescriptor: HelloReply.Descriptor.FindFieldByNumber(HelloReply.ValuesFieldNumber))); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(JsonValueKind.Array, responseJson.RootElement.ValueKind); + Assert.Equal("One", responseJson.RootElement[0].GetString()); + Assert.Equal("Two", responseJson.RootElement[1].GetString()); + Assert.Equal("", responseJson.RootElement[2].GetString()); + } + + [Fact] + public async Task HandleCallAsync_RootBodySet_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor)); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonFormatter.Default.Format(new HelloRequest + { + Name = "TestName!" + }))); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!", + ["sub.subfield"] = "QueryStringTestSubfield!" + }); + httpContext.Request.ContentType = "application/json"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("TestName!", request!.Name); + Assert.Null(request!.Sub); + } + + [Fact] + public async Task HandleCallAsync_SubBodySet_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "sub", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonFormatter.Default.Format(new HelloRequest.Types.SubMessage + { + Subfield = "Subfield!" + }))); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!", + ["sub.subfield"] = "QueryStringTestSubfield!", + ["sub.subfields"] = "QueryStringTestSubfields!" + }); + httpContext.Request.ContentType = "application/json"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("QueryStringTestName!", request!.Name); + Assert.Equal("Subfield!", request!.Sub.Subfield); + Assert.Empty(request!.Sub.Subfields); + } + + [Fact] + public async Task HandleCallAsync_SubRepeatedBodySet_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "repeated_strings", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, + bodyDescriptorRepeated: true, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + + var sdf = new RepeatedField + { + "One", + "Two", + "Three" + }; + + var sw = new StringWriter(); + JsonFormatter.Default.WriteValue(sw, sdf); + + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(sw.ToString())); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!", + ["sub.subfield"] = "QueryStringTestSubfield!", + ["sub.subfields"] = "QueryStringTestSubfields!" + }); + httpContext.Request.ContentType = "application/json"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("QueryStringTestName!", request!.Name); + Assert.Equal("QueryStringTestSubfield!", request!.Sub.Subfield); + Assert.Equal(3, request!.RepeatedStrings.Count); + Assert.Equal("One", request!.RepeatedStrings[0]); + Assert.Equal("Two", request!.RepeatedStrings[1]); + Assert.Equal("Three", request!.RepeatedStrings[2]); + } + + [Fact] + public async Task HandleCallAsync_SubSubRepeatedBodySet_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "sub.subfields", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, + bodyDescriptorRepeated: true, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + + var sdf = new RepeatedField + { + "One", + "Two", + "Three" + }; + + var sw = new StringWriter(); + JsonFormatter.Default.WriteValue(sw, sdf); + + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(sw.ToString())); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!", + ["sub.subfield"] = "QueryStringTestSubfield!" // Not bound because query can't be applied to fields that are covered by body + }); + httpContext.Request.ContentType = "application/json"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("QueryStringTestName!", request!.Name); + Assert.Equal("QueryStringTestSubfield!", request!.Sub.Subfield); + Assert.Equal(3, request!.Sub.Subfields.Count); + } + + [Fact] + public async Task HandleCallAsync_MatchingQueryStringValues_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "TestName!", + ["sub.subfield"] = "TestSubfield!" + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("TestName!", request!.Name); + Assert.Equal("TestSubfield!", request!.Sub.Subfield); + } + + [Fact] + public async Task HandleCallAsync_SuccessfulResponse_DefaultValuesInResponseJson() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "TestName!" + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("TestName!", request!.Name); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("", responseJson.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData("{malformed_json}", "Request JSON payload is not correctly formatted.")] + [InlineData("{\"name\": 1234}", "Request JSON payload is not correctly formatted.")] + //[InlineData("{\"abcd\": 1234}", "Unknown field: abcd")] + public async Task HandleCallAsync_MalformedRequestBody_BadRequestReturned(string json, string expectedError) + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor)); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); + httpContext.Request.ContentType = "application/json"; + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(400, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.InvalidArgument, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Theory] + [InlineData(null)] + [InlineData("text/html")] + public async Task HandleCallAsync_BadContentType_BadRequestReturned(string contentType) + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor)); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}")); + httpContext.Request.ContentType = contentType; + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(400, httpContext.Response.StatusCode); + + var expectedError = "Request content-type of application/json is required."; + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.InvalidArgument, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Fact] + public async Task HandleCallAsync_RpcExceptionReturned_StatusReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromException(new RpcException(new Status(StatusCode.Unauthenticated, "Detail!"), "Message!")); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(401, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("Detail!", responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal("Detail!", responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.Unauthenticated, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Fact] + public async Task HandleCallAsync_RpcExceptionThrown_StatusReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + throw new RpcException(new Status(StatusCode.Unauthenticated, "Detail!"), "Message!"); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(401, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("Detail!", responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal("Detail!", responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.Unauthenticated, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Fact] + public async Task HandleCallAsync_StatusSet_StatusReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + c.Status = new Status(StatusCode.Unauthenticated, "Detail!"); + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(401, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(@"Detail!", responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(@"Detail!", responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.Unauthenticated, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Fact] + public async Task HandleCallAsync_UserState_HttpContextInUserState() + { + object? requestHttpContext = null; + + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + c.UserState.TryGetValue("__HttpContext", out requestHttpContext); + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(httpContext, requestHttpContext); + } + + [Fact] + public async Task HandleCallAsync_HasInterceptor_InterceptorCalled() + { + object? interceptorRun = null; + + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + c.UserState.TryGetValue("IntercepterRun", out interceptorRun); + return Task.FromResult(new HelloReply()); + }; + + var interceptors = new List<(Type Type, object[] Args)>(); + interceptors.Add((typeof(TestInterceptor), Args: Array.Empty())); + + var unaryServerCallHandler = CreateCallHandler(invoker, interceptors: interceptors); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.True((bool)interceptorRun!); + } + + public class TestInterceptor : Interceptor + { + public override Task UnaryServerHandler(TRequest request, ServerCallContext context, UnaryServerMethod continuation) + { + context.UserState["IntercepterRun"] = true; + return base.UnaryServerHandler(request, context, continuation); + } + } + + [Fact] + public async Task HandleCallAsync_GetHostAndMethodAndPeer_MatchHandler() + { + string? peer = null; + string? host = null; + string? method = null; + + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + peer = c.Peer; + host = c.Host; + method = c.Method; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal("ipv4:127.0.0.1:0", peer); + Assert.Equal("localhost", host); + Assert.Equal("/ServiceName/TestMethodName", method); + } + + [Fact] + public async Task HandleCallAsync_ExceptionThrown_StatusReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + throw new InvalidOperationException("Exception!"); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(500, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("Exception was thrown by handler.", responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal("Exception was thrown by handler.", responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.Unknown, responseJson.RootElement.GetProperty("code").GetInt32()); + } + + [Fact] + public async Task HandleCallAsync_MatchingRepeatedQueryStringValues_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["sub.subfields"] = new StringValues(new[] { "TestSubfields1!", "TestSubfields2!" }) + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal(2, request!.Sub.Subfields.Count); + Assert.Equal("TestSubfields1!", request!.Sub.Subfields[0]); + Assert.Equal("TestSubfields2!", request!.Sub.Subfields[1]); + } + + [Fact] + public async Task HandleCallAsync_DataTypes_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["data.single_int32"] = "1", + ["data.single_int64"] = "2", + ["data.single_uint32"] = "3", + ["data.single_uint64"] = "4", + ["data.single_sint32"] = "5", + ["data.single_sint64"] = "6", + ["data.single_fixed32"] = "7", + ["data.single_fixed64"] = "8", + ["data.single_sfixed32"] = "9", + ["data.single_sfixed64"] = "10", + ["data.single_float"] = "11.1", + ["data.single_double"] = "12.1", + ["data.single_bool"] = "true", + ["data.single_string"] = "A string", + ["data.single_bytes"] = Convert.ToBase64String(new byte[] { 1, 2, 3 }), + ["data.single_enum"] = "FOO", + ["data.single_message.subfield"] = "Nested string" + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal(1, request!.Data.SingleInt32); + Assert.Equal(2, request!.Data.SingleInt64); + Assert.Equal((uint)3, request!.Data.SingleUint32); + Assert.Equal((ulong)4, request!.Data.SingleUint64); + Assert.Equal(5, request!.Data.SingleSint32); + Assert.Equal(6, request!.Data.SingleSint64); + Assert.Equal((uint)7, request!.Data.SingleFixed32); + Assert.Equal((ulong)8, request!.Data.SingleFixed64); + Assert.Equal(9, request!.Data.SingleSfixed32); + Assert.Equal(10, request!.Data.SingleSfixed64); + Assert.Equal(11.1, request!.Data.SingleFloat, 3); + Assert.Equal(12.1, request!.Data.SingleDouble, 3); + Assert.True(request!.Data.SingleBool); + Assert.Equal("A string", request!.Data.SingleString); + Assert.Equal(new byte[] { 1, 2, 3 }, request!.Data.SingleBytes.ToByteArray()); + Assert.Equal(HelloRequest.Types.DataTypes.Types.NestedEnum.Foo, request!.Data.SingleEnum); + Assert.Equal("Nested string", request!.Data.SingleMessage.Subfield); + } + + [Fact] + public async Task HandleCallAsync_GetHttpContext_ReturnValue() + { + HttpContext? httpContext = null; + var request = await ExecuteUnaryHandler(handler: (r, c) => + { + httpContext = c.GetHttpContext(); + return Task.FromResult(new HelloReply()); + }); + + // Assert + Assert.NotNull(httpContext); + } + + [Fact] + public async Task HandleCallAsync_ServerCallContextFeature_ReturnValue() + { + IServerCallContextFeature? feature = null; + var request = await ExecuteUnaryHandler(handler: (r, c) => + { + feature = c.GetHttpContext().Features.Get(); + return Task.FromResult(new HelloReply()); + }); + + // Assert + Assert.NotNull(feature); + } + + [Theory] + [InlineData("0", HelloRequest.Types.DataTypes.Types.NestedEnum.Unspecified)] + [InlineData("1", HelloRequest.Types.DataTypes.Types.NestedEnum.Foo)] + [InlineData("2", HelloRequest.Types.DataTypes.Types.NestedEnum.Bar)] + [InlineData("3", HelloRequest.Types.DataTypes.Types.NestedEnum.Baz)] + [InlineData("-1", HelloRequest.Types.DataTypes.Types.NestedEnum.Neg)] + public async Task HandleCallAsync_IntegerEnum_SetOnRequestMessage(string value, HelloRequest.Types.DataTypes.Types.NestedEnum expectedEnum) + { + var request = await ExecuteUnaryHandler(httpContext => + { + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["data.single_enum"] = value + }); + }); + + // Assert + Assert.Equal(expectedEnum, request.Data.SingleEnum); + } + + [Theory] + [InlineData("99")] + [InlineData("INVALID")] + public async Task HandleCallAsync_InvalidEnum_Error(string value) + { + await ExecuteUnaryHandler(httpContext => + { + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["data.single_enum"] = value + }); + }); + + var exceptionWrite = TestSink.Writes.Single(w => w.EventId.Name == "ErrorExecutingServiceMethod"); + Assert.Equal($"Invalid value '{value}' for enum type NestedEnum.", exceptionWrite.Exception.Message); + } + + private async Task ExecuteUnaryHandler( + Action? configureHttpContext = null, + Func>? handler = null) + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return handler != null ? handler(r, c) : Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + configureHttpContext?.Invoke(httpContext); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + return request!; + } + + [Fact] + public async Task HandleCallAsync_Wrappers_SetOnRequestMessage() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["wrappers.string_value"] = "1", + ["wrappers.int32_value"] = "2", + ["wrappers.int64_value"] = "3", + ["wrappers.float_value"] = "4.1", + ["wrappers.double_value"] = "5.1", + ["wrappers.bool_value"] = "true", + ["wrappers.uint32_value"] = "7", + ["wrappers.uint64_value"] = "8", + ["wrappers.bytes_value"] = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("1", request!.Wrappers.StringValue); + Assert.Equal(2, request!.Wrappers.Int32Value); + Assert.Equal(3, request!.Wrappers.Int64Value); + Assert.Equal(4.1, request!.Wrappers.FloatValue.GetValueOrDefault(), 3); + Assert.Equal(5.1, request!.Wrappers.DoubleValue.GetValueOrDefault(), 3); + Assert.Equal(true, request!.Wrappers.BoolValue); + Assert.Equal((uint)7, request!.Wrappers.Uint32Value.GetValueOrDefault()); + Assert.Equal((ulong)8, request!.Wrappers.Uint64Value.GetValueOrDefault()); + Assert.Equal(new byte[] { 1, 2, 3 }, request!.Wrappers.BytesValue.ToByteArray()); + } + + [Fact] + public async Task HandleCallAsync_Any_Success() + { + // Arrange + HelloRequest? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply + { + AnyMessage = Any.Pack(new StringValue { Value = "A value!" }) + }); + }; + + var typeRegistry = TypeRegistry.FromMessages(StringValue.Descriptor, Int32Value.Descriptor); + var jsonFormatter = new JsonFormatter(new JsonFormatter.Settings(formatDefaultValues: true, typeRegistry)); + + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor), + JsonTranscodingOptions: new GrpcJsonTranscodingOptions + { + TypeRegistry = typeRegistry + }); + var httpContext = TestHelpers.CreateHttpContext(); + var requestJson = jsonFormatter.Format(new HelloRequest + { + Name = "Test", + AnyMessage = Any.Pack(new Int32Value { Value = 123 }) + }); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestJson)); + httpContext.Request.ContentType = "application/json"; + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("Test", request!.Name); + Assert.Equal("type.googleapis.com/google.protobuf.Int32Value", request!.AnyMessage.TypeUrl); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + + var anyMessage = responseJson.RootElement.GetProperty("anyMessage"); + Assert.Equal("type.googleapis.com/google.protobuf.StringValue", anyMessage.GetProperty("@type").GetString()); + Assert.Equal("A value!", anyMessage.GetProperty("value").GetString()); + } + + private UnaryServerCallHandler CreateCallHandler( + UnaryServerMethod invoker, + CallHandlerDescriptorInfo? descriptorInfo = null, + List<(Type Type, object[] Args)>? interceptors = null, + GrpcJsonTranscodingOptions? JsonTranscodingOptions = null) + { + var serviceOptions = new GrpcServiceOptions(); + if (interceptors != null) + { + foreach (var interceptor in interceptors) + { + serviceOptions.Interceptors.Add(interceptor.Type, interceptor.Args ?? Array.Empty()); + } + } + + var unaryServerCallInvoker = new UnaryServerMethodInvoker( + invoker, + CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + MethodOptions.Create(new[] { serviceOptions }), + new TestGrpcServiceActivator()); + + var jsonContext = new JsonContext( + JsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings(), + JsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); + + return new UnaryServerCallHandler( + unaryServerCallInvoker, + LoggerFactory, + descriptorInfo ?? TestHelpers.CreateDescriptorInfo(), + JsonConverterHelper.CreateSerializerOptions(jsonContext)); + } + + public static Marshaller GetMarshaller(MessageParser parser) where TMessage : IMessage => + Marshallers.Create(r => r.ToByteArray(), data => parser.ParseFrom(data)); + + public static readonly Method ServiceMethod = CreateServiceMethod("MethodName", HelloRequest.Parser, HelloReply.Parser); + + public static Method CreateServiceMethod(string methodName, MessageParser requestParser, MessageParser responseParser) + where TRequest : IMessage + where TResponse : IMessage + { + return new Method(MethodType.Unary, "ServiceName", methodName, GetMarshaller(requestParser), GetMarshaller(responseParser)); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/annotations.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/annotations.proto new file mode 100644 index 00000000000..85c361b47fe --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright (c) 2015, Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/http.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/http.proto new file mode 100644 index 00000000000..b2977f51474 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/http.proto @@ -0,0 +1,376 @@ +// Copyright 2019 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs new file mode 100644 index 00000000000..4b93fe06ed8 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Count; +using Greet; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.Swagger; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests; + +public class GrpcSwaggerServiceExtensionsTests +{ + [Fact] + public void AddGrpcSwagger_GrpcServiceRegistered_ReturnSwaggerWithGrpcOperation() + { + // Arrange & Act + var services = new ServiceCollection(); + services.AddGrpcSwagger(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + }); + services.AddRouting(); + services.AddLogging(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var app = new ApplicationBuilder(serviceProvider); + + app.UseRouting(); + app.UseEndpoints(c => + { + c.MapGrpcService(); + }); + + var swaggerGenerator = serviceProvider.GetRequiredService(); + var swagger = swaggerGenerator.GetSwagger("v1"); + + // Assert + Assert.NotNull(swagger); + Assert.Single(swagger.Paths); + + var path = swagger.Paths["/v1/greeter/{name}"]; + Assert.True(path.Operations.ContainsKey(OperationType.Get)); + } + + [Fact] + public void AddGrpcSwagger_GrpcServiceWithGroupName_FilteredByGroup() + { + // Arrange & Act + var services = new ServiceCollection(); + services.AddGrpcSwagger(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + c.SwaggerDoc("v2", new OpenApiInfo { Title = "My API", Version = "v2" }); + }); + services.AddRouting(); + services.AddLogging(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var app = new ApplicationBuilder(serviceProvider); + + app.UseRouting(); + app.UseEndpoints(c => + { + c.MapGrpcService(); + c.MapGrpcService(); + }); + + var swaggerGenerator = serviceProvider.GetRequiredService(); + + // Assert 1 + var swagger = swaggerGenerator.GetSwagger("v1"); + Assert.Single(swagger.Paths); + Assert.True(swagger.Paths["/v1/greeter/{name}"].Operations.ContainsKey(OperationType.Get)); + + // Assert 2 + swagger = swaggerGenerator.GetSwagger("v2"); + Assert.Equal(2, swagger.Paths.Count); + Assert.True(swagger.Paths["/v1/greeter/{name}"].Operations.ContainsKey(OperationType.Get)); + Assert.True(swagger.Paths["/v1/add/{value1}/{value2}"].Operations.ContainsKey(OperationType.Get)); + } + + private class TestWebHostEnvironment : IWebHostEnvironment + { + public IFileProvider WebRootFileProvider { get; set; } + public string WebRootPath { get; set; } + public string ApplicationName { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + public string ContentRootPath { get; set; } + public string EnvironmentName { get; set; } + } + + private class GreeterService : Greeter.GreeterBase + { + } + + [ApiExplorerSettings(GroupName = "v2")] + private class CounterService : Counter.CounterBase + { + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj new file mode 100644 index 00000000000..a77de42bd5c --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj @@ -0,0 +1,17 @@ + + + $(DefaultNetCoreTargetFramework) + true + $(NoWarn);CS1591 + + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/counter.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/counter.proto new file mode 100644 index 00000000000..4e9bfc41f8c --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/counter.proto @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; + +package count; + +service Counter { + rpc Add (CountRequest) returns (CountReply) { + option (google.api.http) = { + get: "/v1/add/{value1}/{value2}" + }; + } +} + +message CountRequest { + int32 value1 = 1; + int32 value2 = 2; +} + +message CountReply { + int32 result = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/greeter.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/greeter.proto new file mode 100644 index 00000000000..22295f3d902 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/greeter.proto @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; + +package greet; + +// Greeter! +service Greeter { + // SayHello! + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } +} + +// HelloRequest! +message HelloRequest { + // Name! + string name = 1; +} + +// HelloReply! +message HelloReply { + // Message! + string message = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/messages.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/messages.proto new file mode 100644 index 00000000000..6938a545f59 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/messages.proto @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/protobuf/wrappers.proto"; + +package messages; + +message HelloRequest { + message SubMessage { + string subfield = 1; + repeated string subfields = 2; + } + message DataTypes { + enum NestedEnum { + NESTED_ENUM_UNSPECIFIED = 0; + FOO = 1; + BAR = 2; + BAZ = 3; + NEG = -1; // Intentionally negative. + } + message NestedMessage { + string subfield = 1; + } + int32 single_int32 = 1; + int64 single_int64 = 2; + uint32 single_uint32 = 3; + uint64 single_uint64 = 4; + sint32 single_sint32 = 5; + sint64 single_sint64 = 6; + fixed32 single_fixed32 = 7; + fixed64 single_fixed64 = 8; + sfixed32 single_sfixed32 = 9; + sfixed64 single_sfixed64 = 10; + float single_float = 11; + double single_double = 12; + bool single_bool = 13; + string single_string = 14; + bytes single_bytes = 15; + NestedEnum single_enum = 16; + NestedMessage single_message = 17; + } + message Wrappers { + google.protobuf.StringValue string_value = 1; + google.protobuf.Int32Value int32_value = 2; + google.protobuf.Int64Value int64_value = 3; + google.protobuf.FloatValue float_value = 4; + google.protobuf.DoubleValue double_value = 5; + google.protobuf.BoolValue bool_value = 6; + google.protobuf.UInt32Value uint32_value = 7; + google.protobuf.UInt64Value uint64_value = 8; + google.protobuf.BytesValue bytes_value = 9; + } + string name = 1; + SubMessage sub = 2; + DataTypes data = 3; + Wrappers wrappers = 4; + repeated string repeated_strings = 5; +} + +message HelloReply { + string message = 1; + repeated string values = 2; +} + +message StringWrapper { + google.protobuf.StringValue string_value = 1; +} + +message RecursiveMessage { + RecursiveMessage child = 1; +} + +message BytesMessage { + bytes bytes_value = 1; + google.protobuf.BytesValue bytes_nullable_value = 2; +} + +message EnumMessage { + enum NestedEnum { + NESTED_ENUM_UNSPECIFIED = 0; + FOO = 1; + BAR = 2; + BAZ = 3; + NEG = -1; // Intentionally negative. + } + NestedEnum enum_value = 1; +} + +message OneOfMessage { + oneof First { + string first_one = 1; + string first_two = 2; + } + oneof Second { + string second_one = 3; + string second_two = 4; + } +} + +message MapMessage { + map map_value = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/xmldoc.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/xmldoc.proto new file mode 100644 index 00000000000..1b4b730d249 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/xmldoc.proto @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; + +package xmldoc; + +// XmlDoc! +service XmlDoc { + // BasicGet! + rpc BasicGet (StringRequest) returns (StringReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + // BodyRootPost! + rpc BodyRootPost (StringRequestWithDetail) returns (StringReply) { + option (google.api.http) = { + post: "/v1/greeter", + body: "*" + }; + } + // BodyPathPost! + rpc BodyPathPost (StringRequestWithDetail) returns (StringReply) { + option (google.api.http) = { + post: "/v1/greeter/{name}", + body: "detail" + }; + } + // BasicDelete! + rpc BasicDelete (StringRequest) returns (StringReply) { + option (google.api.http) = { + delete: "/v1/greeter/{name}" + }; + } +} + +// StringRequest! +message StringRequest { + // Name field! + string name = 1; +} + +// StringRequestWithDetail! +message StringRequestWithDetail { + // Name field! + string name = 1; + // Detail field! + StringRequestWithDetail.Detail detail = 2; + + // Detail! + message Detail { + // Age field! + int32 age = 1; + } +} + +// StringReply! +message StringReply { + // Message field! + string message = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/SchemaGeneratorIntegrationTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/SchemaGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..b22088886e2 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/SchemaGeneratorIntegrationTests.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Google.Protobuf.WellKnownTypes; +using Messages; +using Microsoft.AspNetCore.Grpc.Swagger.Internal; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests; + +public class SchemaGeneratorIntegrationTests +{ + private (OpenApiSchema Schema, SchemaRepository SchemaRepository) GenerateSchema(System.Type type) + { + var dataContractResolver = new GrpcDataContractResolver(new JsonSerializerDataContractResolver(new JsonSerializerOptions())); + var schemaGenerator = new SchemaGenerator(new SchemaGeneratorOptions(), dataContractResolver); + var schemaRepository = new SchemaRepository(); + + var schema = schemaGenerator.GenerateSchema(type, schemaRepository); + + return (schema, schemaRepository); + } + + [Fact] + public void GenerateSchema_EnumValue_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(EnumMessage)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(1, schema.Properties.Count); + + var enumSchema = repository.Schemas[schema.Properties["enumValue"].Reference.Id]; + Assert.Equal("string", enumSchema.Type); + Assert.Equal(5, enumSchema.Enum.Count); + Assert.Equal("NESTED_ENUM_UNSPECIFIED", ((OpenApiString)enumSchema.Enum[0]).Value); + Assert.Equal("FOO", ((OpenApiString)enumSchema.Enum[1]).Value); + Assert.Equal("BAR", ((OpenApiString)enumSchema.Enum[2]).Value); + Assert.Equal("BAZ", ((OpenApiString)enumSchema.Enum[3]).Value); + Assert.Equal("NEG", ((OpenApiString)enumSchema.Enum[4]).Value); + } + + [Fact] + public void GenerateSchema_BasicMessage_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(HelloReply)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(2, schema.Properties.Count); + Assert.Equal("string", schema.Properties["message"].Type); + var valuesSchema = schema.Properties["values"]; + Assert.Equal("array", valuesSchema.Type); + Assert.NotNull(valuesSchema.Items); + Assert.Equal("string", valuesSchema.Items.Type); + } + + [Fact] + public void GenerateSchema_RecursiveMessage_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(RecursiveMessage)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(1, schema.Properties.Count); + Assert.Equal("RecursiveMessage", schema.Properties["child"].Reference.Id); + } + + [Fact] + public void GenerateSchema_BytesMessage_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(BytesMessage)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(2, schema.Properties.Count); + Assert.Equal("string", schema.Properties["bytesValue"].Type); + Assert.Equal("string", schema.Properties["bytesNullableValue"].Type); + } + + [Fact] + public void GenerateSchema_ListValues_ReturnSchema() + { + // Arrange & Act + var (schema, _) = GenerateSchema(typeof(ListValue)); + + // Assert + Assert.Equal("array", schema.Type); + Assert.NotNull(schema.Items); + Assert.Null(schema.Items.Type); + } + + [Fact] + public void GenerateSchema_Struct_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(Struct)); + + _ = repository.Schemas.Count; + + // Assert + Assert.Equal("Struct", schema.Reference.Id); + + var resolvedSchema = repository.Schemas[schema.Reference.Id]; + + Assert.Equal("object", resolvedSchema.Type); + Assert.Equal(0, resolvedSchema.Properties.Count); + Assert.NotNull(resolvedSchema.AdditionalProperties); + Assert.Null(resolvedSchema.AdditionalProperties.Type); + } + + [Fact] + public void GenerateSchema_Any_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(Any)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.NotNull(schema.AdditionalProperties); + Assert.Null(schema.AdditionalProperties.Type); + Assert.Equal(1, schema.Properties.Count); + Assert.Equal("string", schema.Properties["@type"].Type); + } + + [Fact] + public void GenerateSchema_OneOf_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(OneOfMessage)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(4, schema.Properties.Count); + Assert.Equal("string", schema.Properties["firstOne"].Type); + Assert.Equal("string", schema.Properties["firstTwo"].Type); + Assert.Equal("string", schema.Properties["secondOne"].Type); + Assert.Equal("string", schema.Properties["secondTwo"].Type); + Assert.Null(schema.AdditionalProperties); + } + + [Fact] + public void GenerateSchema_Map_ReturnSchema() + { + // Arrange & Act + var (schema, repository) = GenerateSchema(typeof(MapMessage)); + + // Assert + schema = repository.Schemas[schema.Reference.Id]; + Assert.Equal("object", schema.Type); + Assert.Equal(1, schema.Properties.Count); + Assert.Equal("object", schema.Properties["mapValue"].Type); + Assert.Equal("number", schema.Properties["mapValue"].AdditionalProperties.Type); + Assert.Equal("double", schema.Properties["mapValue"].AdditionalProperties.Format); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/GreeterService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/GreeterService.cs new file mode 100644 index 00000000000..c7a961fa154 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/GreeterService.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Greet; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; + +public class GreeterService : Greeter.GreeterBase +{ + private readonly ILogger _logger; + + public GreeterService(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + public override Task SayHello(HelloRequest request, ServerCallContext context) + { + _logger.LogInformation($"Sending hello to {request.Name}"); + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocService.cs new file mode 100644 index 00000000000..3da58b7575e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocService.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Xmldoc; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; + +public class XmlDocService : XmlDoc.XmlDocBase +{ + private readonly ILogger _logger; + + public XmlDocService(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + public override Task BasicGet(StringRequest request, ServerCallContext context) + { + return base.BasicGet(request, context); + } + + public override Task BodyRootPost(StringRequestWithDetail request, ServerCallContext context) + { + return base.BodyRootPost(request, context); + } + + public override Task BodyPathPost(StringRequestWithDetail request, ServerCallContext context) + { + return base.BodyPathPost(request, context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocServiceWithComments.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocServiceWithComments.cs new file mode 100644 index 00000000000..642bc91bda1 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/XmlDocServiceWithComments.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Xmldoc; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; + +/// +/// XmlDocServiceWithComments XML comment! +/// +public class XmlDocServiceWithComments : XmlDoc.XmlDocBase +{ + private readonly ILogger _logger; + + public XmlDocServiceWithComments(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// BasicGet XML summary! + /// + /// + /// BasicGet XML remarks! + /// + /// Request XML comment! + /// + /// Returns the newly created item! + /// Not found! + /// Returns comment! + public override Task BasicGet(StringRequest request, ServerCallContext context) + { + return base.BasicGet(request, context); + } + + /// + /// BodyRootPost XML summary! + /// + /// Request XML param! + /// + /// + public override Task BodyRootPost(StringRequestWithDetail request, ServerCallContext context) + { + return base.BodyRootPost(request, context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlCommentsDocumentFilterTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlCommentsDocumentFilterTests.cs new file mode 100644 index 00000000000..6d9e0c3cc0d --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlCommentsDocumentFilterTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Xml.XPath; +using Grpc.AspNetCore.Server; +using Grpc.Core; +using Microsoft.AspNetCore.Grpc.Swagger.Internal.XmlComments; +using Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.XmlComments; + +public class XmlCommentsDocumentFilterTests +{ + private class TestMethod : IMethod + { + public MethodType Type { get; } + public string ServiceName { get; } = "TestServiceName"; + public string Name { get; } = "TestName"; + public string FullName => ServiceName + "." + Name; + } + + [Theory] + [InlineData(typeof(XmlDocService), "XmlDoc!")] + [InlineData(typeof(XmlDocServiceWithComments), "XmlDocServiceWithComments XML comment!")] + public void Apply_SetsTagDescription_FromControllerSummaryTags(Type serviceType, string expectedDescription) + { + var document = new OpenApiDocument(); + var filterContext = new DocumentFilterContext( + new[] + { + CreateApiDescription(serviceType), + CreateApiDescription(serviceType) + }, + null, + null); + + Subject().Apply(document, filterContext); + + Assert.Equal(1, document.Tags.Count); + Assert.Equal(expectedDescription, document.Tags[0].Description); + + static ApiDescription CreateApiDescription(Type serviceType) + { + return new ApiDescription + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = + { + ["controller"] = "greet.Greeter" + }, + EndpointMetadata = new List + { + new GrpcMethodMetadata(serviceType, new TestMethod()) + } + } + }; + } + } + + private GrpcXmlCommentsDocumentFilter Subject() + { + using (var xmlComments = File.OpenText($"{typeof(GreeterService).Assembly.GetName().Name}.xml")) + { + return new GrpcXmlCommentsDocumentFilter(new XPathDocument(xmlComments)); + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs new file mode 100644 index 00000000000..63beb63caf6 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Greet; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; +using Swashbuckle.AspNetCore.Swagger; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.XmlComments; + +public class XmlDocumentationIntegrationTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public XmlDocumentationIntegrationTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public void ServiceDescription_ModelHasXmlDocs_UseXmlDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + Assert.Equal("xmldoc.XmlDoc", swagger.Tags[0].Name); + Assert.Equal("XmlDocServiceWithComments XML comment!", swagger.Tags[0].Description); + } + + [Fact] + public void ServiceDescription_ModelDoesntHaveXmlDocs_UseProtoDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + Assert.Equal("xmldoc.XmlDoc", swagger.Tags[0].Name); + Assert.Equal("XmlDoc!", swagger.Tags[0].Description); + } + + [Fact] + public void RouteParameter_UseProtoDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter/{name}"]; + Assert.Equal("Name field!", path.Operations[OperationType.Get].Parameters[0].Description); + } + + [Fact] + public void MethodDescription_ModelHasXmlDocs_UseXmlDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter/{name}"]; + Assert.Equal("BasicGet XML summary!", path.Operations[OperationType.Get].Summary); + Assert.Equal("BasicGet XML remarks!", path.Operations[OperationType.Get].Description); + } + + [Fact] + public void MethodDescription_ModelDoesntHaveXmlDocs_UseProtoDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter/{name}"]; + Assert.Equal("BasicGet!", path.Operations[OperationType.Get].Summary); + Assert.Null(path.Operations[OperationType.Get].Description); + } + + [Fact] + public void RequestDescription_Root_ModelHasXmlDocs_UseXmlDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter"]; + Assert.Equal("Request XML param!", path.Operations[OperationType.Post].RequestBody.Description); + } + + [Fact] + public void RequestDescription_Root_ModelDoesntHaveXmlDocs_Empty() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter"]; + Assert.Null(path.Operations[OperationType.Post].RequestBody.Description); + } + + [Fact] + public void RequestDescription_Nested_ProtoFieldDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var path = swagger.Paths["/v1/greeter/{name}"]; + Assert.Equal("Detail field!", path.Operations[OperationType.Post].RequestBody.Description); + } + + [Fact] + public void Message_UseProtoDocs() + { + // Arrange & Act + var swagger = GetOpenApiDocument(); + + // Assert + var helloReplyMessage = swagger.Components.Schemas["StringReply"]; + Assert.Equal("StringReply!", helloReplyMessage.Description); + Assert.Equal("Message field!", helloReplyMessage.Properties["message"].Description); + } + + private OpenApiDocument GetOpenApiDocument() where TService : class + { + var services = new ServiceCollection(); + services.AddGrpcSwagger(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + + var filePath = Path.Combine(System.AppContext.BaseDirectory, "Microsoft.AspNetCore.Grpc.Swagger.Tests.xml"); + c.IncludeXmlComments(filePath); + c.IncludeGrpcXmlComments(filePath, includeControllerXmlComments: true); + }); + services.AddRouting(); + services.AddLogging(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var app = new ApplicationBuilder(serviceProvider); + + app.UseRouting(); + app.UseEndpoints(c => + { + c.MapGrpcService(); + }); + + var swaggerGenerator = serviceProvider.GetRequiredService(); + var swagger = swaggerGenerator.GetSwagger("v1"); + + using var outputString = new StringWriter(); + swagger.SerializeAsV3(new OpenApiJsonWriter(outputString)); + _testOutputHelper.WriteLine(outputString.ToString()); + + return swagger; + } + + private class TestWebHostEnvironment : IWebHostEnvironment + { + public IFileProvider WebRootFileProvider { get; set; } + public string WebRootPath { get; set; } + public string ApplicationName { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + public string ContentRootPath { get; set; } + public string EnvironmentName { get; set; } + } + + private class GreeterService : Greeter.GreeterBase + { + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/annotations.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/annotations.proto new file mode 100644 index 00000000000..85c361b47fe --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright (c) 2015, Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/http.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/http.proto new file mode 100644 index 00000000000..b2977f51474 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/google/api/http.proto @@ -0,0 +1,376 @@ +// Copyright 2019 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/Shared/TestGrpcServiceActivator.cs b/src/Grpc/JsonTranscoding/test/Shared/TestGrpcServiceActivator.cs new file mode 100644 index 00000000000..ab929b60294 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Shared/TestGrpcServiceActivator.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.AspNetCore.Server; + +namespace Grpc.Tests.Shared; + +internal class TestGrpcServiceActivator : IGrpcServiceActivator where TGrpcService : class, new() +{ + public GrpcActivatorHandle Create(IServiceProvider serviceProvider) + { + return new GrpcActivatorHandle(new TGrpcService(), false, null); + } + + public ValueTask ReleaseAsync(GrpcActivatorHandle service) + { + return default; + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicEndpointDataSource.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicEndpointDataSource.cs new file mode 100644 index 00000000000..e6d04a87e8d --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicEndpointDataSource.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Primitives; + +namespace IntegrationTestsWebsite.Infrastructure; + +/// +/// This endpoint data source can be modified and will raise a change token event. +/// It can be used to add new endpoints after the application has started. +/// +public class DynamicEndpointDataSource : EndpointDataSource +{ + private readonly List _endpoints = new List(); + private CancellationTokenSource? _cts; + private CancellationChangeToken? _cct; + + public override IReadOnlyList Endpoints => _endpoints; + + public override IChangeToken GetChangeToken() + { + if (_cts == null) + { + _cts = new CancellationTokenSource(); + } + if (_cct == null) + { + _cct = new CancellationChangeToken(_cts.Token); + } + + return _cct; + } + + public void AddEndpoints(IEnumerable endpoints) + { + // Avoid ambiguous match result when the same URL is used between tests + foreach (var newEndpoint in endpoints) + { + if (newEndpoint is RouteEndpoint routeEndpoint) + { + var existingMatch = _endpoints + .OfType() + .SingleOrDefault(e => e.RoutePattern.RawText == routeEndpoint.RoutePattern.RawText); + if (existingMatch != null) + { + _endpoints.Remove(existingMatch); + } + } + + _endpoints.Add(newEndpoint); + } + + if (_cts != null) + { + var localCts = _cts; + + _cts = null; + _cct = null; + + localCts.Cancel(); + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicService.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicService.cs new file mode 100644 index 00000000000..da78b78d549 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicService.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IntegrationTestsWebsite.Infrastructure; + +public class DynamicService +{ +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicServiceModelProvider.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicServiceModelProvider.cs new file mode 100644 index 00000000000..9736c25ada0 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Infrastructure/DynamicServiceModelProvider.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Grpc.AspNetCore.Server.Model; + +namespace IntegrationTestsWebsite.Infrastructure; + +public class DynamicServiceModelProvider : IServiceMethodProvider +{ + public Action>? CreateMethod { get; set; } + + public void OnServiceMethodDiscovery(ServiceMethodProviderContext context) + { + Debug.Assert(CreateMethod != null); + + CreateMethod(context); + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/IntegrationTestsWebsite.csproj b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/IntegrationTestsWebsite.csproj new file mode 100644 index 00000000000..bdbc150f5a4 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/IntegrationTestsWebsite.csproj @@ -0,0 +1,13 @@ + + + $(DefaultNetCoreTargetFramework) + enable + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Program.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Program.cs new file mode 100644 index 00000000000..2b91b5ae8cd --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Program.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace IntegrationTestsWebsite; + +public class Program +{ + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Properties/launchSettings.json b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Properties/launchSettings.json new file mode 100644 index 00000000000..b38188c71ed --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "IntegrationTestsWebsite": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:55969;http://localhost:55970" + } + } +} \ No newline at end of file diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto new file mode 100644 index 00000000000..97f85431455 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +option csharp_namespace = "IntegrationTestsWebsite"; + +import "google/api/annotations.proto"; + +package greet; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + rpc SayHelloPost (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter", + body: "*" + }; + } +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings. +message HelloReply { + string message = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Services/GreeterService.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Services/GreeterService.cs new file mode 100644 index 00000000000..971533fa111 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Services/GreeterService.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.Core; + +namespace IntegrationTestsWebsite.Services; + +public class GreeterService : Greeter.GreeterBase +{ + private readonly ILogger _logger; + public GreeterService(ILogger logger) + { + _logger = logger; + } + + public override Task SayHello(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply + { + Message = "Hello " + request.Name + }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Startup.cs b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Startup.cs new file mode 100644 index 00000000000..7611309ee7e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Startup.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Grpc.AspNetCore.Server.Model; +using IntegrationTestsWebsite.Infrastructure; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace IntegrationTestsWebsite; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services + .AddGrpc(options => + { + options.EnableDetailedErrors = true; + }) + .AddJsonTranscoding(); + services.AddHttpContextAccessor(); + + // When the site is run from the test project these types will be injected + // This will add a default types if the site is run standalone + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton, DynamicServiceModelProvider>()); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.DataSources.Add(endpoints.ServiceProvider.GetRequiredService()); + }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.Development.json b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.json b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.json new file mode 100644 index 00000000000..1aef5074f6f --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/annotations.proto b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/annotations.proto new file mode 100644 index 00000000000..85c361b47fe --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright (c) 2015, Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/http.proto b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/http.proto new file mode 100644 index 00000000000..b2977f51474 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/google/api/http.proto @@ -0,0 +1,376 @@ +// Copyright 2019 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Controllers/ValuesController.cs b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Controllers/ValuesController.cs new file mode 100644 index 00000000000..c6cbc8686c9 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Controllers/ValuesController.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc; + +namespace Sandbox; + +[Route("api/[controller]")] +[ApiController] +public class ValuesController : ControllerBase +{ + // GET: api/ + [HttpGet] + public IEnumerable Get() + { + return new string[] { "value1", "value2" }; + } + + // GET api//5 + [HttpGet("{id}")] + public string Get(int id) + { + return "value"; + } + + // POST api/ + [HttpPost] + public void Post([FromBody]string value) + { + } + + // PUT api//5 + [HttpPut("{id}")] + public void Put(int id, [FromBody]string value) + { + } + + // DELETE api//5 + [HttpDelete("{id}")] + public void Delete(int id) + { + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Program.cs b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Program.cs new file mode 100644 index 00000000000..7ba46568b0e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Program.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Server; + +public class Program +{ + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Properties/launchSettings.json b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Properties/launchSettings.json new file mode 100644 index 00000000000..fabb4580537 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52105/", + "sslPort": 44365 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Sandbox": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Sandbox.csproj b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Sandbox.csproj new file mode 100644 index 00000000000..fa3e2095d4e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Sandbox.csproj @@ -0,0 +1,14 @@ + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Services/GreeterService.cs b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Services/GreeterService.cs new file mode 100644 index 00000000000..10ed34c8c35 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Services/GreeterService.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Greet; +using Grpc.Core; + +namespace Server; + +public class JsonTranscodingGreeterService : Transcoding.JsonTranscodingGreeter.JsonTranscodingGreeterBase +{ + +} + +public class GreeterService : Greeter.GreeterBase +{ + private readonly ILogger _logger; + + public GreeterService(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Say hello. + /// + /// + /// + /// + public override Task SayHello(HelloRequest request, ServerCallContext context) + { + _logger.LogInformation($"Sending hello to {request.Name}"); + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" }); + } + + public override Task SayHelloFrom(HelloRequestFrom request, ServerCallContext context) + { + _logger.LogInformation($"Sending hello to {request.Name} from {request.From}"); + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} from {request.From}" }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Startup.cs b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Startup.cs new file mode 100644 index 00000000000..52e10479fc5 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/Startup.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Server; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddGrpc().AddJsonTranscoding(); + services.AddMvc(); + + #region Secret + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + }); + services.AddGrpcSwagger(); + #endregion + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + #region Secret + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); + }); + #endregion + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + endpoints.MapGrpcService(); + }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.Development.json b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.Development.json new file mode 100644 index 00000000000..fe20c40cc7e --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Grpc": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.json b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.json new file mode 100644 index 00000000000..6a845cfd49d --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/annotations.proto b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/annotations.proto new file mode 100644 index 00000000000..85c361b47fe --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright (c) 2015, Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/http.proto b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/http.proto new file mode 100644 index 00000000000..b2977f51474 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api/http.proto @@ -0,0 +1,376 @@ +// Copyright 2019 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/greet.proto b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/greet.proto new file mode 100644 index 00000000000..4d121a7abcc --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/greet.proto @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; + +package greet; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + rpc SayHelloFrom (HelloRequestFrom) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "*" + }; + } +} + +message HelloRequest { + string name = 1; +} + +message HelloRequestFrom { + string name = 1; + string from = 2; +} + +message HelloReply { + string message = 1; + HelloReply nested = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/testassets/Sandbox/transcoding.proto b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/transcoding.proto new file mode 100644 index 00000000000..a802ffa2c82 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/testassets/Sandbox/transcoding.proto @@ -0,0 +1,169 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/struct.proto"; + +package transcoding; + +service JsonTranscodingGreeter { + rpc SayHello (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "/v11/greeter8/{name}" + }; + } + rpc ResponseBody (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "/v11/greeter7/{name}" + response_body: "message" + }; + } + rpc Custom (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + custom: { + kind: "HEAD", + path: "/v1/greeter6/{name}" + } + }; + } + rpc AdditionalBindings (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "/v1/additional_bindings/{name}" + additional_bindings { + delete: "/v1/additional_bindings/{name}" + } + }; + } + rpc NoOption (HelloRequest) returns (HelloReply2); + rpc ServerStreamingGetOption (HelloRequest) returns (stream HelloReply2) { + option (google.api.http) = { + get: "/v11/greeter5/{name}" + }; + } + rpc Body (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + post: "/v11/greeter4" + body: "*" + }; + } + rpc SubBody (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + post: "/v11/greeter3" + body: "sub" + }; + } + rpc SubRepeatedBody (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + post: "/v11/greeter2" + body: "repeated_strings" + }; + } + rpc ParameterRoute (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + post: "/v11/greeter2/{data.single_int32}" + body: "repeated_strings" + }; + } + rpc ReturnListValue (HelloRequest) returns (google.protobuf.ListValue) { + option (google.api.http) = { + post: "/v11/list_value" + }; + } + rpc ReturnValue (HelloRequest) returns (google.protobuf.Value) { + option (google.api.http) = { + post: "/v11/value" + }; + } + rpc ReturnStruct (HelloRequest) returns (google.protobuf.Struct) { + option (google.api.http) = { + post: "/v11/struct" + }; + } +} + +service JsonTranscodingInvalidResponseBodyGreeter { + rpc BadResponseBody (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + response_body: "NoMatch" + }; + } +} + +service JsonTranscodingInvalidBodyGreeter { + rpc BadBody (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + body: "NoMatch" + }; + } +} + +service JsonTranscodingInvalidPatternGreeter { + rpc BadPattern (HelloRequest) returns (HelloReply2) { + option (google.api.http) = { + get: "v1/greeter/{name}" + body: "NoMatch" + }; + } +} + +message HelloRequest { + message SubMessage { + string subfield = 1; + repeated string subfields = 2; + } + message DataTypes { + enum NestedEnum { + NESTED_ENUM_UNSPECIFIED = 0; + FOO = 1; + BAR = 2; + BAZ = 3; + NEG = -1; // Intentionally negative. + } + message NestedMessage { + string subfield = 1; + } + int32 single_int32 = 1; + int64 single_int64 = 2; + uint32 single_uint32 = 3; + uint64 single_uint64 = 4; + sint32 single_sint32 = 5; + sint64 single_sint64 = 6; + fixed32 single_fixed32 = 7; + fixed64 single_fixed64 = 8; + sfixed32 single_sfixed32 = 9; + sfixed64 single_sfixed64 = 10; + float single_float = 11; + double single_double = 12; + bool single_bool = 13; + string single_string = 14; + bytes single_bytes = 15; + NestedEnum single_enum = 16; + NestedMessage single_message = 17; + } + message Wrappers { + google.protobuf.StringValue string_value = 1; + google.protobuf.Int32Value int32_value = 2; + google.protobuf.Int64Value int64_value = 3; + google.protobuf.FloatValue float_value = 4; + google.protobuf.DoubleValue double_value = 5; + google.protobuf.BoolValue bool_value = 6; + google.protobuf.UInt32Value uint32_value = 7; + google.protobuf.UInt64Value uint64_value = 8; + google.protobuf.BytesValue bytes_value = 9; + } + string name = 1; + SubMessage sub = 2; + DataTypes data = 3; + Wrappers wrappers = 4; + repeated string repeated_strings = 5; +} + +message HelloReply2 { + string message = 1; + repeated string values = 2; +} diff --git a/src/Grpc/THIRD-PARTY-NOTICES b/src/Grpc/THIRD-PARTY-NOTICES index 6b4a863467d..984a61a3a00 100644 --- a/src/Grpc/THIRD-PARTY-NOTICES +++ b/src/Grpc/THIRD-PARTY-NOTICES @@ -8,8 +8,8 @@ bring it to our attention. Post an issue or email us: The attached notices are provided for information only. -License notice for gRPC interop tests -------------------------------------- +License notice for gRPC +----------------------- Copyright 2019 The gRPC Authors @@ -24,3 +24,20 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +--- + +Copyright (c) 2015, Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 3a52e34dd8f..b7241a24363 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -28,7 +28,7 @@ internal class EndpointMetadataApiDescriptionProvider : IApiDescriptionProvider private readonly ParameterBindingMethodCache ParameterBindingMethodCache = new(); private readonly ParameterPolicyFactory _parameterPolicyFactory; - // Executes before MVC's DefaultApiDescriptionProvider and GrpcHttpApiDescriptionProvider for no particular reason. + // Executes before MVC's DefaultApiDescriptionProvider and GrpcJsonTranscodingDescriptionProvider for no particular reason. public int Order => -1100; public EndpointMetadataApiDescriptionProvider(