diff --git a/.squad/agents/fenster/history.md b/.squad/agents/fenster/history.md index b97fa9c779..ec3cf572cf 100644 --- a/.squad/agents/fenster/history.md +++ b/.squad/agents/fenster/history.md @@ -54,3 +54,41 @@ **Conclusion**: This is not a bug in Vogen's code generation. It's **incorrect documentation** that suggests an invalid protobuf-net pattern. The README should be corrected to remove or fix the protobuf example. + +--- + +### 2026-05-15: Enhanced gRPC Scenario with Real Server Testing + +**Task**: Enhance `samples\Vogen.Examples\SerializationAndConversion\Grpc\GrpcScenario.cs` to test real protobuf-net serialization over HTTP/2 gRPC. + +**Implementation**: +- Used in-process ASP.NET Core hosting approach (standard pattern for .NET gRPC integration tests) +- Created real HTTP/2 server with `WebApplication` and Kestrel +- Used `protobuf-net.Grpc.AspNetCore` for server-side code-first gRPC hosting +- Used `Grpc.Net.Client` for client channel creation +- Dynamic port allocation via socket binding to avoid port conflicts + +**Key Patterns Discovered**: +1. **In-process ASP.NET Core testing pattern** is preferred over Docker/Testcontainers for custom gRPC services (unlike MongoDb scenario which uses pre-built images) +2. **Required NuGet packages for code-first gRPC**: + - `protobuf-net.Grpc.AspNetCore` (v1.2.2) - for `AddCodeFirstGrpc()` and `MapGrpcService()` + - `Grpc.Net.Client` (v2.70.0) - for `GrpcChannel.ForAddress()` + - `ProtoBuf.Grpc.Client` namespace - for `channel.CreateGrpcService()` extension + - `ProtoBuf.Grpc.Server` namespace - for `AddCodeFirstGrpc()` extension +3. **Framework reference needed**: Added `` to csproj since it uses `Microsoft.NET.Sdk` (not Web SDK) +4. **Port allocation**: Use socket binding to `IPAddress.Loopback:0` to get OS-assigned available port +5. **HTTP/2 enforcement**: Must configure Kestrel with `HttpProtocols.Http2` explicitly for gRPC +6. **Proper cleanup**: Server must be stopped with `StopAsync()` and disposed in finally block +7. **Error handling**: Wrap in try/catch similar to Mongo scenario pattern + +**Files Modified**: +- `samples\Vogen.Examples\Vogen.Examples.csproj` - Added packages and framework reference +- `samples\Vogen.Examples\SerializationAndConversion\Grpc\GrpcScenario.cs` - Complete rewrite with server hosting + +**Build Verification**: ✓ `dotnet build samples\Vogen.Examples\Vogen.Examples.csproj -c Release` succeeds + +**Architecture Notes**: +- This pattern tests the FULL serialization stack: Vogen value objects → VogenSurrogate → protobuf wire format → HTTP/2 → deserialize back +- The existing `VogenSurrogate` pattern (using `IVogen` static abstracts) works perfectly with code-first gRPC +- No changes needed to value object definitions or surrogate class + diff --git a/.squad/decisions.md b/.squad/decisions.md index fc3104d896..0989297be0 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -57,6 +57,38 @@ public partial struct MyValueObject; --- +## 2026-05-15: In-Process ASP.NET Core Hosting for gRPC Scenario Testing + +**By:** Fenster (Backend Dev) + +**What:** Enhanced GrpcScenario.cs with in-process ASP.NET Core gRPC hosting for real serialization testing. + +**Context:** The original GrpcScenario.cs called the gRPC service directly in-process, which didn't test protobuf-net serialization over the wire. Steve requested enhancement to test real serialization. + +**Decision:** Use in-process ASP.NET Core hosting with WebApplication and Kestrel configured for HTTP/2 instead of Docker/Testcontainers. + +**Implementation:** +- Host gRPC service using WebApplication with Kestrel for HTTP/2 +- Use protobuf-net.Grpc.AspNetCore for code-first gRPC server +- Use Grpc.Net.Client for client channel creation +- Dynamic port allocation via socket binding +- Added `` to Vogen.Examples.csproj + +**Rationale:** +1. **Simpler than Docker:** No custom Dockerfile needed; standard .NET pattern +2. **Full stack validation:** Tests Vogen value objects → protobuf serialization → HTTP/2 transport → deserialization +3. **Fast execution:** No container startup overhead +4. **No external dependencies:** Runs anywhere .NET 8 runs + +**Alternatives Considered:** +- Docker/Testcontainers — would require Dockerfile; overkill for this scenario +- Keep in-process call — doesn't test serialization +- External gRPC server — requires manual setup; not portable + +**Impact:** Tests verify that `VogenSurrogate` pattern works correctly with protobuf-net over real gRPC. Example is more realistic and demonstrates end-to-end integration. + +--- + ## End of decisions New decisions will be merged here by Scribe from `.squad/decisions/inbox/`. diff --git a/docs/site/Writerside/topics/reference/FAQ.md b/docs/site/Writerside/topics/reference/FAQ.md index f9bd4f43b9..5c126405ff 100644 --- a/docs/site/Writerside/topics/reference/FAQ.md +++ b/docs/site/Writerside/topics/reference/FAQ.md @@ -424,6 +424,8 @@ Yes, but the mechanism is different depending on the version of protobuf-net you ### protobuf-net v2 (pre-v3) +> ⚠️ **This pattern is broken in protobuf-net v3 and later.** If you are on v3+, skip to the next section. + Add a dependency to protobuf-net and annotate the value object with `[ProtoContract(Surrogate = typeof(string))]`: (NOTE: replace `string` with the correct primitive) ```c# @@ -469,6 +471,21 @@ public partial class BoxId; public partial class Age; ``` +Any DTO or message class that contains these value objects must also be annotated with `[ProtoContract]` and `[ProtoMember]` +on each property — without these, protobuf-net cannot deserialize the message and will return `null`: + +```c# +[ProtoContract] +public class Person +{ + [ProtoMember(1)] public Name? Name { get; set; } + [ProtoMember(2)] public Age? Age { get; set; } +} +``` + +Note that properties should use `{ get; set; }` (not `init`) and be nullable, as protobuf-net constructs the object +via a parameterless constructor and then sets each property — Vogen prohibits assigning `default` to value object fields. + A full working example including schema generation and a gRPC service can be found in [`samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs`](../../../../samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs). diff --git a/samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs b/samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs index c5ef982246..42f210f958 100644 --- a/samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs +++ b/samples/Vogen.Examples/SerializationAndConversion/Grpc/GrpcScenario.cs @@ -1,8 +1,17 @@ using System; +using System.Net; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using Grpc.Net.Client; using JetBrains.Annotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using ProtoBuf; +using ProtoBuf.Grpc.Client; +using ProtoBuf.Grpc.Server; using System.ServiceModel; namespace Vogen.Examples.SerializationAndConversion.Grpc; @@ -17,19 +26,81 @@ public async Task Run() string schema = generator.GetSchema(); Console.WriteLine("Schema is: " + schema); - IService service = new Service(); - var person = await service.GetDataAsync(); - - Console.WriteLine($"Received Person: name={person.Name}, age={person.Age}, boxId={person.BoxId}, temperature={person.Temperature}"); + try + { + // Find an available port + int port = GetAvailablePort(); + string serverUrl = $"http://127.0.0.1:{port}"; + + Console.WriteLine($"Starting gRPC server on {serverUrl}..."); + + // Build and start the ASP.NET Core gRPC server + var builder = WebApplication.CreateBuilder(); + + // Configure services for gRPC + builder.Services.AddCodeFirstGrpc(); + builder.WebHost.UseUrls(serverUrl); + builder.WebHost.UseKestrel(options => + { + options.Listen(IPAddress.Loopback, port, listenOptions => + { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; + }); + }); + + var app = builder.Build(); + + // Map the gRPC service + app.MapGrpcService(); + + // Start the server in the background + await app.StartAsync(); + + Console.WriteLine("gRPC server started successfully."); + + try + { + // Create a gRPC channel and client + using var channel = GrpcChannel.ForAddress(serverUrl); + var client = channel.CreateGrpcService(); + + Console.WriteLine("Calling gRPC service over HTTP/2..."); + + // Call the service through the real gRPC channel + var person = await client.GetDataAsync(); + + Console.WriteLine($"✓ Received Person over gRPC: name={person.Name}, age={person.Age}, boxId={person.BoxId}, temperature={person.Temperature}"); + Console.WriteLine("✓ protobuf-net serialization over gRPC verified successfully!"); + } + finally + { + // Shutdown the server + await app.StopAsync(); + await app.DisposeAsync(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error running gRPC scenario: {ex.Message}"); + Console.WriteLine("This scenario requires HTTP/2 support."); + } + } + + private static int GetAvailablePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint!).Port; } } +[ProtoContract] public class Person { - public Name Name { get; init; } - public Age Age { get; init; } - public BoxId BoxId { get; init; } - public Temperature Temperature { get; init; } + [ProtoMember(1)] public Name? Name { get; set; } + [ProtoMember(2)] public Age? Age { get; set; } + [ProtoMember(3)] public BoxId? BoxId { get; set; } + [ProtoMember(4)] public Temperature? Temperature { get; set; } } [ServiceContract] @@ -74,11 +145,10 @@ public partial class Temperature; [ProtoContract] public class VogenSurrogate where TW: IVogen { - [ProtoMember(1)] - public TP Value { get; set; } + [ProtoMember(1)] public TP Value { get; set; } = default!; public static implicit operator TW(VogenSurrogate surrogate) => TW.From(surrogate.Value); - public static implicit operator VogenSurrogate(TW value) => new() { Value = value.Value }; + public static implicit operator VogenSurrogate?(TW? value) => value is null ? null : new VogenSurrogate { Value = value.Value }; } diff --git a/samples/Vogen.Examples/Vogen.Examples.csproj b/samples/Vogen.Examples/Vogen.Examples.csproj index 31de9f9383..bbbffb64aa 100644 --- a/samples/Vogen.Examples/Vogen.Examples.csproj +++ b/samples/Vogen.Examples/Vogen.Examples.csproj @@ -22,8 +22,10 @@ + + @@ -43,6 +45,10 @@ + + + +