Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/parity/g13-grpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,22 @@ ARCHITECTURE.md called for. Validated against the **official WireMock gRPC exten
scalars, **maps, enums, oneofs**, and the well-known wrapper types in the codec; gRPC **status/error**
responses and no-match semantics; and gRPC admin reset. Each builds on this codec + middleware.
- **Regression case:** `G13aGrpcTests.UnaryCall_MatchesTheOracle`.

## Codec expansion — enum / map / repeated (G13b)

- **Group / item:** G13b — validated over the wire against WireMock + its gRPC extension.
- **More proto3 field kinds.** `ProtobufJsonCodec` now covers, driven by the descriptor:
- **`enum`** — decoded to its value *name* (proto3 JSON), encoded from name (or number).
- **`map<k,v>`** — a map field is repeated entry messages on the wire; decoded to a JSON object and
encoded back to entries. (A decoded entry value is detached from its entry before being placed in
the map — a `JsonNode` may have only one parent.)
- **repeated** — both **packed** scalar/enum (a single length-delimited run, decoded until the
sub-stream ends) and **unpacked** (repeated tags); written unpacked, which every reader accepts.
- **Validation.** A `Describe` call carries a repeated `string`, an `enum` (`GREEN`), and a
`map<string,int32>` in the request, and a repeated (packed) `int32` in the reply. The same stub is
served by the oracle and Mockifyr; the decoded replies (`summary`, `codes`) must match — so the
request JSON Mockifyr decodes matches the stub's `equalToJson` exactly as the reference extension's
does, across all these field kinds.
- **Deferred (tracked):** `oneof` and the well-known wrapper types in the codec; streaming; gRPC
status/error responses; gRPC admin reset.
- **Regression case:** `G13bGrpcCodecTests.Describe_WithEnumMapRepeated_MatchesTheOracle`.
8 changes: 6 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,12 @@ Detailed rationale and per-group contents:
`DynamicMessage`) + a gRPC HTTP/2 middleware that decodes the call, routes it through the
**unchanged** `StubEngine` as a POST to `/service/method`, and re-encodes the response. Descriptors
from `<root-dir>/grpc/*.dsc`. Validated against the **official WireMock gRPC extension** oracle over
TLS (a unary `SayHello` reply matches). Streaming, maps/enums/oneofs, status responses, and gRPC
admin reset deferred. See docs/parity/g13-grpc.md
TLS (a unary `SayHello` reply matches). See docs/parity/g13-grpc.md
- [x] G13b Codec expansion — `ProtobufJsonCodec` now covers `enum` (by value name), `map` (as a JSON
object ↔ entry messages), and repeated fields (packed + unpacked), driven by the descriptor.
Validated against the oracle with a `Describe` call carrying repeated/enum/map in and a packed
repeated `int32` out. `oneof`/wrappers, streaming, status responses, and gRPC admin reset deferred.
See docs/parity/g13-grpc.md
- [ ] **G14** GraphQL extension
- [ ] **G15** Message-based/WebSocket + JWT + Faker + multi-domain
- [x] **G16** Persistence providers (FileBased/LiteDB/Postgres/Redis) + change-feed reload
Expand Down
126 changes: 105 additions & 21 deletions src/Mockifyr.Facade.Grpc/ProtobufJsonCodec.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Google.Protobuf;
using Google.Protobuf.Reflection;
Expand All @@ -12,56 +13,94 @@ namespace Mockifyr.Facade.Grpc;
/// protobuf-java-util form WireMock's gRPC extension produces), so the resulting JSON flows through the
/// unchanged engine (<c>equalToJson</c> matching, <c>jsonBody</c> responses).
///
/// <para>Covered: the proto3 scalars, <c>string</c>/<c>bytes</c> (base64), nested messages, and
/// (non-packed) repeated fields. 64-bit integers render as JSON strings, per proto3 JSON. Deferred:
/// packed repeated scalars, maps, enums, oneofs, and the well-known wrapper types.</para>
/// <para>Covered: the proto3 scalars, <c>string</c>/<c>bytes</c> (base64), nested messages, repeated
/// fields (packed and unpacked), <c>enum</c> (by value name), and <c>map</c> (as a JSON object).
/// 64-bit integers render as JSON strings, per proto3 JSON. Deferred: <c>oneof</c> and the well-known
/// wrapper types.</para>
/// </summary>
public static class ProtobufJsonCodec
{
/// <summary>Decodes a protobuf message into a JSON object using its descriptor.</summary>
public static JsonObject Decode(MessageDescriptor descriptor, byte[] bytes)
{
var stream = new CodedInputStream(bytes);
return Decode(descriptor, stream);
}
public static JsonObject Decode(MessageDescriptor descriptor, byte[] bytes) => Decode(descriptor, new CodedInputStream(bytes));

private static JsonObject Decode(MessageDescriptor descriptor, CodedInputStream stream)
{
var result = new JsonObject();
uint tag;
while ((tag = stream.ReadTag()) != 0)
{
var fieldNumber = WireFormat.GetTagFieldNumber(tag);
var field = descriptor.FindFieldByNumber(fieldNumber);
var field = descriptor.FindFieldByNumber(WireFormat.GetTagFieldNumber(tag));
if (field is null)
{
stream.SkipLastField();
continue;
}

var value = ReadValue(field, stream);
if (field.IsRepeated)
if (field.IsMap)
{
var map = result[field.JsonName] as JsonObject;
if (map is null)
{
map = [];
result[field.JsonName] = map;
}

var (key, value) = DecodeMapEntry(field, stream.ReadBytes().ToByteArray());
map[key] = value;
}
else if (field.IsRepeated)
{
if (result[field.JsonName] is not JsonArray array)
var array = result[field.JsonName] as JsonArray;
if (array is null)
{
array = [];
result[field.JsonName] = array;
}

array.Add(value);
if (IsPackable(field) && WireFormat.GetTagWireType(tag) == WireFormat.WireType.LengthDelimited)
{
var packed = new CodedInputStream(stream.ReadBytes().ToByteArray());
while (!packed.IsAtEnd)
{
array.Add(ReadScalar(field, packed));
}
}
else
{
array.Add(ReadValue(field, stream));
}
}
else
{
result[field.JsonName] = value;
result[field.JsonName] = ReadValue(field, stream);
}
}

return result;
}

private static (string Key, JsonNode? Value) DecodeMapEntry(FieldDescriptor field, byte[] entryBytes)
{
var keyField = field.MessageType.FindFieldByNumber(1);
var valueField = field.MessageType.FindFieldByNumber(2);
var entry = Decode(field.MessageType, entryBytes);
// proto3 JSON map keys are always strings; the value keeps its natural JSON type. Detach the
// value from the entry object before returning it (a JsonNode can have only one parent).
var key = entry.TryGetPropertyValue(keyField.JsonName, out var k) && k is not null ? k.ToString() : string.Empty;
var value = entry.TryGetPropertyValue(valueField.JsonName, out var v) ? v?.DeepClone() : null;
return (key, value);
}

private static JsonNode? ReadValue(FieldDescriptor field, CodedInputStream stream) => field.FieldType switch
{
FieldType.String => stream.ReadString(),
FieldType.Bytes => Convert.ToBase64String(stream.ReadBytes().ToByteArray()),
FieldType.Message => Decode(field.MessageType, stream.ReadBytes().ToByteArray()),
_ => ReadScalar(field, stream),
};

private static JsonNode? ReadScalar(FieldDescriptor field, CodedInputStream stream) => field.FieldType switch
{
FieldType.Bool => stream.ReadBool(),
FieldType.Int32 => stream.ReadInt32(),
FieldType.SInt32 => stream.ReadSInt32(),
Expand All @@ -76,8 +115,10 @@ private static JsonObject Decode(MessageDescriptor descriptor, CodedInputStream
FieldType.SFixed64 => stream.ReadSFixed64().ToString(),
FieldType.UInt64 => stream.ReadUInt64().ToString(),
FieldType.Fixed64 => stream.ReadFixed64().ToString(),
FieldType.Bytes => Convert.ToBase64String(stream.ReadBytes().ToByteArray()),
FieldType.Message => Decode(field.MessageType, stream.ReadBytes().ToByteArray()),
// Enums render as the value name in proto3 canonical JSON (falling back to the number).
FieldType.Enum => field.EnumType.FindValueByNumber(stream.ReadEnum())?.Name is { } name
? name
: throw new NotSupportedException($"Unknown enum value on field '{field.Name}'."),
_ => throw new NotSupportedException($"gRPC field type {field.FieldType} is not supported yet (field '{field.Name}')."),
};

Expand All @@ -100,10 +141,19 @@ private static void Encode(MessageDescriptor descriptor, JsonObject json, CodedO
continue;
}

if (field.IsRepeated)
if (field.IsMap)
{
foreach (var (key, value) in node.AsObject())
{
stream.WriteTag(field.FieldNumber, WireFormat.WireType.LengthDelimited);
stream.WriteBytes(ByteString.CopyFrom(EncodeMapEntry(field, key, value)));
}
}
else if (field.IsRepeated)
{
if (node is JsonArray array)
{
// Repeated fields are written unpacked; every protobuf reader accepts both forms.
foreach (var element in array)
{
WriteValue(field, element, stream);
Expand All @@ -117,6 +167,26 @@ private static void Encode(MessageDescriptor descriptor, JsonObject json, CodedO
}
}

private static byte[] EncodeMapEntry(FieldDescriptor field, string key, JsonNode? value)
{
var keyField = field.MessageType.FindFieldByNumber(1);
var valueField = field.MessageType.FindFieldByNumber(2);
var entry = new JsonObject
{
// A map key arrives as a JSON property name (string); restore its natural type for encoding.
[keyField.JsonName] = KeyNode(keyField, key),
[valueField.JsonName] = value?.DeepClone(),
};
return Encode(field.MessageType, entry);
}

private static JsonNode KeyNode(FieldDescriptor keyField, string key) => keyField.FieldType switch
{
FieldType.String => key,
FieldType.Bool => bool.Parse(key),
_ => long.Parse(key),
};

// Accept either the proto3 JSON name (camelCase) or the original field name, like protobuf's parser.
private static bool TryGetProperty(JsonObject json, FieldDescriptor field, out JsonNode? node) =>
json.TryGetPropertyValue(field.JsonName, out node) || json.TryGetPropertyValue(field.Name, out node);
Expand Down Expand Up @@ -186,6 +256,10 @@ private static void WriteValue(FieldDescriptor field, JsonNode? node, CodedOutpu
stream.WriteTag(field.FieldNumber, WireFormat.WireType.Fixed64);
stream.WriteSFixed64(AsLong(node));
break;
case FieldType.Enum:
stream.WriteTag(field.FieldNumber, WireFormat.WireType.Varint);
stream.WriteEnum(EnumNumber(field, node));
break;
case FieldType.Bytes:
stream.WriteTag(field.FieldNumber, WireFormat.WireType.LengthDelimited);
stream.WriteBytes(ByteString.CopyFrom(Convert.FromBase64String(node.GetValue<string>())));
Expand All @@ -199,13 +273,23 @@ private static void WriteValue(FieldDescriptor field, JsonNode? node, CodedOutpu
}
}

// An enum value may arrive as its name (proto3 JSON) or its number; resolve to the wire number.
private static int EnumNumber(FieldDescriptor field, JsonNode node) =>
node.GetValueKind() == JsonValueKind.String
? field.EnumType.FindValueByName(node.GetValue<string>())?.Number
?? throw new NotSupportedException($"Unknown enum name '{node.GetValue<string>()}' on field '{field.Name}'.")
: (int)AsLong(node);

private static bool IsPackable(FieldDescriptor field) =>
field.FieldType is not (FieldType.String or FieldType.Bytes or FieldType.Message or FieldType.Group);

// JSON numbers may arrive as numbers or (for 64-bit) strings; accept both.
private static long AsLong(JsonNode node) =>
node.GetValueKind() == System.Text.Json.JsonValueKind.String ? long.Parse(node.GetValue<string>()) : node.GetValue<long>();
node.GetValueKind() == JsonValueKind.String ? long.Parse(node.GetValue<string>()) : node.GetValue<long>();

private static double AsDouble(JsonNode node) =>
node.GetValueKind() == System.Text.Json.JsonValueKind.String ? double.Parse(node.GetValue<string>()) : node.GetValue<double>();
node.GetValueKind() == JsonValueKind.String ? double.Parse(node.GetValue<string>()) : node.GetValue<double>();

private static bool AsBool(JsonNode node) =>
node.GetValueKind() == System.Text.Json.JsonValueKind.String ? bool.Parse(node.GetValue<string>()) : node.GetValue<bool>();
node.GetValueKind() == JsonValueKind.String ? bool.Parse(node.GetValue<string>()) : node.GetValue<bool>();
}
95 changes: 95 additions & 0 deletions tests/Mockifyr.Differential.Tests/G13bGrpcCodecTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Mockifyr.Differential.Harness;
using Mockifyr.Grpc.Test;
using Mockifyr.Server;

namespace Mockifyr.Differential.Tests;

/// <summary>
/// Differential validation of the expanded protobuf codec (G13b): a richer <c>Describe</c> call
/// exercises a repeated string field, an <c>enum</c> (by name), and a <c>map</c> on the request, and a
/// packed repeated <c>int32</c> on the reply. The same stub is served by the oracle (WireMock + gRPC
/// extension) and Mockifyr, and the decoded replies must match — proving Mockifyr's codec produces the
/// same proto3-JSON the reference extension does across these field kinds. Requires Docker.
/// </summary>
public sealed class G13bGrpcCodecTests : IAsyncLifetime
{
private const string MappingJson =
"""
{
"request": {
"method": "POST",
"urlPath": "/mockifyr.grpc.test.Greeter/Describe",
"bodyPatterns": [ { "equalToJson": "{ \"tags\": [\"a\", \"b\"], \"color\": \"GREEN\", \"counts\": { \"x\": 1, \"y\": 2 } }" } ]
},
"response": {
"status": 200,
"jsonBody": { "summary": "2 tags", "codes": [10, 20, 30] }
}
}
""";

private static byte[] Descriptor() => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Protos", "greeter.dsc"));

private WireMockGrpcOracle _oracle = null!;

public Task InitializeAsync()
{
_oracle = new WireMockGrpcOracle(Descriptor(), MappingJson);
return _oracle.StartAsync();
}

public async Task DisposeAsync() => await _oracle.DisposeAsync();

[Fact]
public async Task Describe_WithEnumMapRepeated_MatchesTheOracle()
{
var root = Directory.CreateTempSubdirectory("mockifyr-grpc-b-");
Directory.CreateDirectory(Path.Combine(root.FullName, "mappings"));
Directory.CreateDirectory(Path.Combine(root.FullName, "grpc"));
File.WriteAllText(Path.Combine(root.FullName, "mappings", "stub.json"), MappingJson);
File.WriteAllBytes(Path.Combine(root.FullName, "grpc", "greeter.dsc"), Descriptor());

await using var mockifyr = MockifyrHost.Build(["--port", "0", "--https-port", "0", "--root-dir", root.FullName]);
await mockifyr.StartAsync();
try
{
var mockifyrAddress = new Uri(mockifyr.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses
.First(a => a.StartsWith("https://", StringComparison.Ordinal))
.Replace("[::]", "127.0.0.1").Replace("0.0.0.0", "127.0.0.1"));

var oracle = await DescribeAsync(_oracle.GrpcAddress);
var mockifyrReply = await DescribeAsync(mockifyrAddress);

Assert.Equal(oracle.Summary, mockifyrReply.Summary);
Assert.Equal(oracle.Codes, mockifyrReply.Codes);
Assert.Equal("2 tags", mockifyrReply.Summary);
Assert.Equal(new[] { 10, 20, 30 }, mockifyrReply.Codes);
}
finally
{
root.Delete(recursive: true);
}
}

private static async Task<DescribeReply> DescribeAsync(Uri address)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions { HttpHandler = handler });
var client = new Greeter.GreeterClient(channel);

var request = new DescribeRequest { Color = Color.Green };
request.Tags.Add(new[] { "a", "b" });
request.Counts.Add("x", 1);
request.Counts.Add("y", 2);
return await client.DescribeAsync(request);
}
}
Binary file modified tests/Mockifyr.Differential.Tests/Protos/greeter.dsc
Binary file not shown.
21 changes: 20 additions & 1 deletion tests/Mockifyr.Differential.Tests/Protos/greeter.proto
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ package mockifyr.grpc.test;

option csharp_namespace = "Mockifyr.Grpc.Test";

// A minimal unary service for the gRPC differential (G13a): one request field in, one out.
// A unary service for the gRPC differential. SayHello (G13a) is the minimal case; Describe (G13b)
// exercises repeated fields, an enum, a map, and a packed repeated reply.
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc Describe (DescribeRequest) returns (DescribeReply);
}

message HelloRequest {
Expand All @@ -16,3 +18,20 @@ message HelloRequest {
message HelloReply {
string message = 1;
}

enum Color {
COLOR_UNSPECIFIED = 0;
RED = 1;
GREEN = 2;
}

message DescribeRequest {
repeated string tags = 1;
Color color = 2;
map<string, int32> counts = 3;
}

message DescribeReply {
string summary = 1;
repeated int32 codes = 2;
}
Loading