diff --git a/examples-Aspire/AspireApp1.AppHost/AspireApp1.AppHost.csproj b/examples-Aspire/AspireApp1.AppHost/AspireApp1.AppHost.csproj index f05544701..cf43ca011 100644 --- a/examples-Aspire/AspireApp1.AppHost/AspireApp1.AppHost.csproj +++ b/examples-Aspire/AspireApp1.AppHost/AspireApp1.AppHost.csproj @@ -21,4 +21,13 @@ - + + + PreserveNewest + + + PreserveNewest + + + + \ No newline at end of file diff --git a/examples-Aspire/AspireApp1.AppHost/Program.cs b/examples-Aspire/AspireApp1.AppHost/Program.cs index 451cf92ef..3ed786573 100644 --- a/examples-Aspire/AspireApp1.AppHost/Program.cs +++ b/examples-Aspire/AspireApp1.AppHost/Program.cs @@ -4,12 +4,25 @@ // IResourceBuilder apiService = builder.AddProject("apiservice"); -var mappingsPath = Path.Combine(Directory.GetCurrentDirectory(), "WireMockMappings"); +var mappingsPath = Path.Combine(Directory.GetCurrentDirectory(), "__admin", "mappings"); -IResourceBuilder apiService = builder - .AddWireMock("apiservice", WireMockServerArguments.DefaultPort) +//IResourceBuilder apiService1 = builder +// //.AddWireMock("apiservice", WireMockServerArguments.DefaultPort) +// .AddWireMock("apiservice1", "http://*:8081", "grpc://*:9091") +// .AsHttp2Service() +// .WithMappingsPath(mappingsPath) +// .WithReadStaticMappings() +// .WithWatchStaticMappings() +// .WithApiMappingBuilder(WeatherForecastApiMock.BuildAsync); + +IResourceBuilder apiService2 = builder + .AddWireMock("apiservice", async args => + { + args.WithAdditionalUrls("http://*:8081", "grpc://*:9093"); + args.WithProtoDefinition("my-greeter", await File.ReadAllTextAsync(Path.Combine(mappingsPath, "greet.proto"))); + }) + .AsHttp2Service() .WithMappingsPath(mappingsPath) - .WithReadStaticMappings() .WithWatchStaticMappings() .WithApiMappingBuilder(WeatherForecastApiMock.BuildAsync); @@ -45,7 +58,7 @@ builder.AddProject("webfrontend") .WithExternalHttpEndpoints() - .WithReference(apiService) - .WaitFor(apiService); + .WithReference(apiService2) + .WaitFor(apiService2); -builder.Build().Run(); \ No newline at end of file +await builder.Build().RunAsync(); \ No newline at end of file diff --git a/examples-Aspire/AspireApp1.AppHost/WireMockMappings/873d495f-940e-4b86-a1f4-4f0fc7be8b8b.json b/examples-Aspire/AspireApp1.AppHost/__admin/mappings/873d495f-940e-4b86-a1f4-4f0fc7be8b8b.json similarity index 100% rename from examples-Aspire/AspireApp1.AppHost/WireMockMappings/873d495f-940e-4b86-a1f4-4f0fc7be8b8b.json rename to examples-Aspire/AspireApp1.AppHost/__admin/mappings/873d495f-940e-4b86-a1f4-4f0fc7be8b8b.json diff --git a/examples-Aspire/AspireApp1.AppHost/__admin/mappings/greet.proto b/examples-Aspire/AspireApp1.AppHost/__admin/mappings/greet.proto new file mode 100644 index 000000000..f4e1ead02 --- /dev/null +++ b/examples-Aspire/AspireApp1.AppHost/__admin/mappings/greet.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package greet; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; + enum PhoneType { + none = 0; + mobile = 1; + home = 2; + } + PhoneType phoneType = 2; +} \ No newline at end of file diff --git a/examples-Aspire/AspireApp1.AppHost/__admin/mappings/protobuf-mapping-4.json b/examples-Aspire/AspireApp1.AppHost/__admin/mappings/protobuf-mapping-4.json new file mode 100644 index 000000000..930dc941f --- /dev/null +++ b/examples-Aspire/AspireApp1.AppHost/__admin/mappings/protobuf-mapping-4.json @@ -0,0 +1,40 @@ +{ + "Guid": "351f0240-bba0-4bcb-93c6-1feba0fe0004", + "Title": "ProtoBuf Mapping 4", + "Request": { + "Path": { + "Matchers": [ + { + "Name": "WildcardMatcher", + "Pattern": "/greet.Greeter/SayHello", + "IgnoreCase": false + } + ] + }, + "Methods": [ + "POST" + ], + "Body": { + "Matcher": { + "Name": "ProtoBufMatcher", + "ProtoBufMessageType": "greet.HelloRequest" + } + } + }, + "Response": { + "BodyAsJson": { + "message": "hello {{request.BodyAsJson.name}} {{request.method}}" + }, + "UseTransformer": true, + "TransformerType": "Handlebars", + "TransformerReplaceNodeOptions": "EvaluateAndTryToConvert", + "Headers": { + "Content-Type": "application/grpc" + }, + "TrailingHeaders": { + "grpc-status": "0" + }, + "ProtoBufMessageType": "greet.HelloReply" + }, + "ProtoDefinition": "my-greeter" +} \ No newline at end of file diff --git a/src/WireMock.Net.Abstractions/Admin/Mappings/StatusModel.cs b/src/WireMock.Net.Abstractions/Admin/Mappings/StatusModel.cs index 2259bc599..45e73528d 100644 --- a/src/WireMock.Net.Abstractions/Admin/Mappings/StatusModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Mappings/StatusModel.cs @@ -24,4 +24,13 @@ public class StatusModel /// The error message. /// public string? Error { get; set; } + + /// + /// Returns a string that represents the current status model, including its unique identifier, status, and error information. + /// + /// A string containing the values of the Guid, Status, and Error properties formatted for display. + public override string ToString() + { + return $"StatusModel [Guid={Guid}, Status={Status}, Error={Error}]"; + } } \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMock.Net.Aspire.csproj b/src/WireMock.Net.Aspire/WireMock.Net.Aspire.csproj index a835e090f..088098d4f 100644 --- a/src/WireMock.Net.Aspire/WireMock.Net.Aspire.csproj +++ b/src/WireMock.Net.Aspire/WireMock.Net.Aspire.csproj @@ -30,7 +30,10 @@ - + + + + diff --git a/src/WireMock.Net.Aspire/WireMockMappingState.cs b/src/WireMock.Net.Aspire/WireMockMappingState.cs index 2bb82108a..a8e6c01b7 100644 --- a/src/WireMock.Net.Aspire/WireMockMappingState.cs +++ b/src/WireMock.Net.Aspire/WireMockMappingState.cs @@ -4,7 +4,9 @@ namespace WireMock.Net.Aspire; internal enum WireMockMappingState { - NoMappings, - NotSubmitted, - Submitted, -} + NoMappings = 0, + + NotSubmitted = 1, + + Submitted = 2 +} \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMockServerArguments.cs b/src/WireMock.Net.Aspire/WireMockServerArguments.cs index 8c059c1a1..3a28aefa7 100644 --- a/src/WireMock.Net.Aspire/WireMockServerArguments.cs +++ b/src/WireMock.Net.Aspire/WireMockServerArguments.cs @@ -1,7 +1,9 @@ // Copyright © WireMock.Net using System.Diagnostics.CodeAnalysis; +using Stef.Validation; using WireMock.Client.Builders; +using WireMock.Util; // ReSharper disable once CheckNamespace namespace Aspire.Hosting; @@ -21,10 +23,15 @@ public class WireMockServerArguments private const string DefaultLogger = "WireMockConsoleLogger"; /// - /// The HTTP port where WireMock.Net is listening. + /// The HTTP ports where WireMock.Net is listening on. /// If not defined, .NET Aspire automatically assigns a random port. /// - public int? HttpPort { get; set; } + public List HttpPorts { get; set; } = []; + + /// + /// Additional Urls on which WireMock listens. + /// + public List AdditionalUrls { get; set; } = []; /// /// The admin username. @@ -67,6 +74,42 @@ public class WireMockServerArguments /// public Func? ApiMappingBuilder { get; set; } + /// + /// Grpc ProtoDefinitions. + /// + public Dictionary ProtoDefinitions { get; set; } = []; + + /// + /// Add an additional Urls on which WireMock should listen. + /// + /// The additional urls which the WireMock Server should listen on. + public void WithAdditionalUrls(params string[] additionalUrls) + { + foreach (var url in additionalUrls) + { + if (!PortUtils.TryExtract(Guard.NotNullOrEmpty(url), out _, out _, out _, out _, out var port)) + { + throw new ArgumentException($"The URL '{url}' is not valid."); + } + + AdditionalUrls.Add(Guard.NotNullOrWhiteSpace(url)); + HttpPorts.Add(port); + } + } + + /// + /// Add a Grpc ProtoDefinition at server-level. + /// + /// Unique identifier for the ProtoDefinition. + /// The ProtoDefinition as text. + public void WithProtoDefinition(string id, params string[] protoDefinitions) + { + Guard.NotNullOrWhiteSpace(id); + Guard.NotNullOrEmpty(protoDefinitions); + + ProtoDefinitions[id] = protoDefinitions; + } + /// /// Converts the current instance's properties to an array of command-line arguments for starting the WireMock.Net server. /// @@ -95,6 +138,11 @@ public string[] GetArgs() Add(args, "--WatchStaticMappingsInSubdirectories", "true"); } + if (AdditionalUrls.Count > 0) + { + Add(args, "--Urls", $"http://*:{HttpContainerPort} {string.Join(' ', AdditionalUrls)}"); + } + return args .SelectMany(k => new[] { k.Key, k.Value }) .ToArray(); diff --git a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs index b19e192e2..07c5dc79f 100644 --- a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs +++ b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs @@ -8,6 +8,7 @@ using Stef.Validation; using WireMock.Client.Builders; using WireMock.Net.Aspire; +using WireMock.Util; // ReSharper disable once CheckNamespace namespace Aspire.Hosting; @@ -34,9 +35,31 @@ public static IResourceBuilder AddWireMock(this IDistrib Guard.NotNullOrWhiteSpace(name); Guard.Condition(port, p => p is null or > 0 and <= ushort.MaxValue); - return builder.AddWireMock(name, callback => + return builder.AddWireMock(name, serverArguments => { - callback.HttpPort = port; + if (port != null) + { + serverArguments.HttpPorts = [port.Value]; + } + }); + } + + /// + /// Adds a WireMock.Net Server resource to the application model. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The additional urls which the WireMock Server should listen on. + /// A reference to the . + public static IResourceBuilder AddWireMock(this IDistributedApplicationBuilder builder, string name, params string[] additionalUrls) + { + Guard.NotNull(builder); + Guard.NotNullOrWhiteSpace(name); + Guard.NotNull(additionalUrls); + + return builder.AddWireMock(name, serverArguments => + { + serverArguments.WithAdditionalUrls(additionalUrls); }); } @@ -67,10 +90,37 @@ public static IResourceBuilder AddWireMock(this IDistrib .AddResource(wireMockContainerResource) .WithImage(DefaultLinuxImage) .WithEnvironment(ctx => ctx.EnvironmentVariables.Add("DOTNET_USE_POLLING_FILE_WATCHER", "1")) // https://khalidabuhakmeh.com/aspnet-docker-gotchas-and-workarounds#configuration-reloads-and-filesystemwatcher - .WithHttpEndpoint(port: arguments.HttpPort, targetPort: WireMockServerArguments.HttpContainerPort) .WithHealthCheck(healthCheckKey) .WithWireMockInspectorCommand(); + if (arguments.HttpPorts.Count == 0) + { + resourceBuilder = resourceBuilder.WithHttpEndpoint(port: null, targetPort: WireMockServerArguments.HttpContainerPort); + } + else if (arguments.HttpPorts.Count == 1) + { + resourceBuilder = resourceBuilder.WithHttpEndpoint(port: arguments.HttpPorts[0], targetPort: WireMockServerArguments.HttpContainerPort); + } + else + { + // Required for the default admin endpoint and health checks + resourceBuilder = resourceBuilder.WithHttpEndpoint(port: null, targetPort: WireMockServerArguments.HttpContainerPort); + + var anyIsHttp2 = false; + foreach (var url in arguments.AdditionalUrls) + { + PortUtils.TryExtract(url, out _, out var isHttp2, out var scheme, out _, out var httpPort); + anyIsHttp2 |= isHttp2; + + resourceBuilder = resourceBuilder.WithEndpoint(port: httpPort, targetPort: httpPort, scheme: scheme, name: $"{scheme}-{httpPort}"); + } + + if (anyIsHttp2) + { + resourceBuilder = resourceBuilder.AsHttp2Service(); + } + } + if (!string.IsNullOrEmpty(arguments.MappingsPath)) { resourceBuilder = resourceBuilder.WithBindMount(arguments.MappingsPath, DefaultLinuxMappingsPath); @@ -84,6 +134,9 @@ public static IResourceBuilder AddWireMock(this IDistrib } }); + // Always add the lifecycle hook to support dynamic mappings and proto definitions + resourceBuilder.ApplicationBuilder.Services.TryAddLifecycleHook(); + return resourceBuilder; } @@ -94,7 +147,10 @@ public static IResourceBuilder AddWireMock(this IDistrib /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// A callback that allows for setting the . /// A reference to the . - public static IResourceBuilder AddWireMock(this IDistributedApplicationBuilder builder, string name, Action callback) + public static IResourceBuilder AddWireMock( + this IDistributedApplicationBuilder builder, + string name, + Action callback) { Guard.NotNull(builder); Guard.NotNullOrWhiteSpace(name); @@ -165,7 +221,7 @@ public static IResourceBuilder WithAdminUserNameAndPassw /// /// The . /// Delegate that will be invoked to configure the WireMock.Net resource. - /// + /// A reference to the . public static IResourceBuilder WithApiMappingBuilder(this IResourceBuilder wiremock, Func configure) { return wiremock.WithApiMappingBuilder((adminApiMappingBuilder, _) => configure.Invoke(adminApiMappingBuilder)); @@ -176,18 +232,31 @@ public static IResourceBuilder WithApiMappingBuilder(thi /// /// The . /// Delegate that will be invoked to configure the WireMock.Net resource. - /// + /// A reference to the . public static IResourceBuilder WithApiMappingBuilder(this IResourceBuilder wiremock, Func configure) { Guard.NotNull(wiremock); - wiremock.ApplicationBuilder.Services.TryAddLifecycleHook(); wiremock.Resource.Arguments.ApiMappingBuilder = configure; wiremock.Resource.ApiMappingState = WireMockMappingState.NotSubmitted; return wiremock; } + /// + /// Add a Grpc ProtoDefinition at server-level. + /// + /// The . + /// Unique identifier for the ProtoDefinition. + /// The ProtoDefinition as text. + /// A reference to the . + public static IResourceBuilder WithProtoDefinition(this IResourceBuilder wiremock, string id, params string[] protoDefinitions) + { + Guard.NotNull(wiremock).Resource.Arguments.WithProtoDefinition(id, protoDefinitions); + + return wiremock; + } + /// /// Enables the WireMockInspect, a cross-platform UI app that facilitates WireMock troubleshooting. /// This requires installation of the WireMockInspector tool. @@ -195,11 +264,11 @@ public static IResourceBuilder WithApiMappingBuilder(thi /// dotnet tool install WireMockInspector --global --no-cache --ignore-failed-sources /// /// - /// The . - /// - public static IResourceBuilder WithWireMockInspectorCommand(this IResourceBuilder builder) + /// The . + /// A reference to the . + public static IResourceBuilder WithWireMockInspectorCommand(this IResourceBuilder wiremock) { - Guard.NotNull(builder); + Guard.NotNull(wiremock); CommandOptions commandOptions = new() { @@ -209,13 +278,13 @@ public static IResourceBuilder WithWireMockInspectorComm IconVariant = IconVariant.Filled }; - builder.WithCommand( + wiremock.WithCommand( name: "wiremock-inspector", displayName: "WireMock Inspector", - executeCommand: _ => OnRunOpenInspectorCommandAsync(builder), + executeCommand: _ => OnRunOpenInspectorCommandAsync(wiremock), commandOptions: commandOptions); - return builder; + return wiremock; } private static Task OnRunOpenInspectorCommandAsync(IResourceBuilder builder) diff --git a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs index 57df9ee81..024724dfe 100644 --- a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs +++ b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs @@ -1,5 +1,6 @@ // Copyright © WireMock.Net +using System.Diagnostics; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Logging; @@ -28,10 +29,12 @@ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, C wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); var endpoint = wireMockServerResource.GetEndpoint(); - System.Diagnostics.Debug.Assert(endpoint.IsAllocated); + Debug.Assert(endpoint.IsAllocated); await wireMockServerResource.WaitForHealthAsync(_linkedCts.Token); + await wireMockServerResource.CallAddProtoDefinitionsAsync(_linkedCts.Token); + await wireMockServerResource.CallApiMappingBuilderActionAsync(_linkedCts.Token); wireMockServerResource.StartWatchingStaticMappings(_linkedCts.Token); diff --git a/src/WireMock.Net.Aspire/WireMockServerResource.cs b/src/WireMock.Net.Aspire/WireMockServerResource.cs index 528b21003..d9529c112 100644 --- a/src/WireMock.Net.Aspire/WireMockServerResource.cs +++ b/src/WireMock.Net.Aspire/WireMockServerResource.cs @@ -70,6 +70,34 @@ internal async Task CallApiMappingBuilderActionAsync(CancellationToken cancellat ApiMappingState = WireMockMappingState.Submitted; } + internal async Task CallAddProtoDefinitionsAsync(CancellationToken cancellationToken) + { + _logger?.LogInformation("Calling AdminApi to add GRPC ProtoDefinition at server level to WireMock.Net"); + + foreach (var (id, protoDefinitions) in Arguments.ProtoDefinitions) + { + _logger?.LogInformation("Adding ProtoDefinition {Id}", id); + foreach (var protoDefinition in protoDefinitions) + { + try + { + var status = await AdminApi.Value.AddProtoDefinitionAsync(id, protoDefinition, cancellationToken); + _logger?.LogInformation("ProtoDefinition '{Id}' added with status: {Status}.", id, status.Status); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error adding ProtoDefinition '{Id}'.", id); + } + } + } + + // Force a reload of static mappings when ProtoDefinitions are added at server-level to fix #1382 + if (Arguments.ProtoDefinitions.Count > 0) + { + await ReloadStaticMappingsAsync(default); + } + } + internal void StartWatchingStaticMappings(CancellationToken cancellationToken) { if (!Arguments.WatchStaticMappings || string.IsNullOrEmpty(Arguments.MappingsPath)) @@ -113,10 +141,17 @@ private IWireMockAdminApi CreateWireMockAdminApi() private async void FileCreatedChangedOrDeleted(object sender, FileSystemEventArgs args) { - _logger?.LogInformation("MappingFile created, changed or deleted: '{0}'. Triggering ReloadStaticMappings.", args.FullPath); + _logger?.LogInformation("MappingFile created, changed or deleted: '{FullPath}'. Triggering ReloadStaticMappings.", args.FullPath); + + await ReloadStaticMappingsAsync(default); + } + + private async Task ReloadStaticMappingsAsync(CancellationToken cancellationToken) + { try { - await AdminApi.Value.ReloadStaticMappingsAsync(); + var status = await AdminApi.Value.ReloadStaticMappingsAsync(cancellationToken); + _logger?.LogInformation("ReloadStaticMappings called with status: {Status}.", status); } catch (Exception ex) { diff --git a/src/WireMock.Net.Minimal/Settings/SimpleSettingsParser.cs b/src/WireMock.Net.Minimal/Settings/SimpleSettingsParser.cs index 5c282c233..a02e06d2c 100644 --- a/src/WireMock.Net.Minimal/Settings/SimpleSettingsParser.cs +++ b/src/WireMock.Net.Minimal/Settings/SimpleSettingsParser.cs @@ -55,7 +55,7 @@ public void Parse(string[] arguments, IDictionary? environment = null) // Now also parse environment if (environment != null) { - foreach (string key in environment.Keys) + foreach (var key in environment.Keys.OfType()) { if (key.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && environment.TryGetStringValue(key, out var value)) { diff --git a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs index 90c2b3357..0dba1c218 100644 --- a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs +++ b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs @@ -153,7 +153,7 @@ private static void ParsePortSettings(WireMockServerSettings settings, SimpleSet } else if (settings.HostingScheme is null) { - settings.Urls = parser.GetValues("Urls", ["http://*:9091/"]); + settings.Urls = parser.GetValues(nameof(WireMockServerSettings.Urls), defaultValue: ["http://*:9091/"]); } } diff --git a/src/WireMock.Net.Minimal/Util/PortUtils.cs b/src/WireMock.Net.Minimal/Util/PortUtils.cs index 1cc2aad8d..d83fc3a01 100644 --- a/src/WireMock.Net.Minimal/Util/PortUtils.cs +++ b/src/WireMock.Net.Minimal/Util/PortUtils.cs @@ -84,22 +84,22 @@ public static IReadOnlyList FindFreeTcpPorts(int count) } /// - /// Extract the isHttps, isHttp2, protocol, host and port from a URL. + /// Extract the isHttps, isHttp2, scheme, host and port from a URL. /// - public static bool TryExtract(string url, out bool isHttps, out bool isHttp2, [NotNullWhen(true)] out string? protocol, [NotNullWhen(true)] out string? host, out int port) + public static bool TryExtract(string url, out bool isHttps, out bool isHttp2, [NotNullWhen(true)] out string? scheme, [NotNullWhen(true)] out string? host, out int port) { isHttps = false; isHttp2 = false; - protocol = null; + scheme = null; host = null; port = 0; var match = UrlDetailsRegex.Match(url); if (match.Success) { - protocol = match.Groups["proto"].Value; - isHttps = protocol.StartsWith("https", StringComparison.OrdinalIgnoreCase) || protocol.StartsWith("grpcs", StringComparison.OrdinalIgnoreCase); - isHttp2 = protocol.StartsWith("grpc", StringComparison.OrdinalIgnoreCase); + scheme = match.Groups["proto"].Value; + isHttps = scheme.StartsWith("https", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("grpcs", StringComparison.OrdinalIgnoreCase); + isHttp2 = scheme.StartsWith("grpc", StringComparison.OrdinalIgnoreCase); host = match.Groups["host"].Value; return int.TryParse(match.Groups["port"].Value, out port); diff --git a/src/WireMock.Net.Testcontainers/WireMockContainer.cs b/src/WireMock.Net.Testcontainers/WireMockContainer.cs index a01a6add4..002a04694 100644 --- a/src/WireMock.Net.Testcontainers/WireMockContainer.cs +++ b/src/WireMock.Net.Testcontainers/WireMockContainer.cs @@ -156,7 +156,8 @@ public async Task ReloadStaticMappingsAsync(CancellationToken cancellationToken try { - await _adminApi.ReloadStaticMappingsAsync(cancellationToken); + var result = await _adminApi.ReloadStaticMappingsAsync(cancellationToken); + Logger.LogInformation("ReloadStaticMappings result: {Result}", result); } catch (Exception ex) { @@ -231,7 +232,8 @@ private async Task CallAdditionalActionsAfterStartedAsync() { try { - await _adminApi!.AddProtoDefinitionAsync(kvp.Key, protoDefinition); + var result = await _adminApi!.AddProtoDefinitionAsync(kvp.Key, protoDefinition); + Logger.LogInformation("AddProtoDefinition '{Id}' result: {Result}", kvp.Key, result); } catch (Exception ex) { @@ -239,6 +241,12 @@ private async Task CallAdditionalActionsAfterStartedAsync() } } } + + // Force a reload of static mappings when ProtoDefinitions are added at server-level to fix #1382 + if (_configuration.ProtoDefinitions.Count > 0) + { + await ReloadStaticMappingsAsync(); + } } private async void FileCreatedChangedOrDeleted(object sender, FileSystemEventArgs args) @@ -246,6 +254,7 @@ private async void FileCreatedChangedOrDeleted(object sender, FileSystemEventArg try { await ReloadStaticMappingsAsync(args.FullPath); + Logger.LogInformation("ReloadStaticMappings triggered from file change: '{FullPath}'.", args.FullPath); } catch (Exception ex) { diff --git a/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs b/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs index ef634ff98..16cf55dd5 100644 --- a/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs +++ b/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs @@ -112,6 +112,7 @@ public WireMockContainerBuilder WithWatchStaticMappings(bool includeSubDirectori { DockerResourceConfiguration.WithWatchStaticMappings(includeSubDirectories); return + WithCommand("--ReadStaticMappings true"). WithCommand("--WatchStaticMappings true"). WithCommand("--WatchStaticMappingsInSubdirectories", includeSubDirectories); } @@ -129,9 +130,7 @@ public WireMockContainerBuilder WithMappings(string path, bool includeSubDirecto DockerResourceConfiguration.WithStaticMappingsPath(path); - return - WithReadStaticMappings(). - WithCommand("--WatchStaticMappingsInSubdirectories", includeSubDirectories); + return WithWatchStaticMappings(includeSubDirectories); } /// diff --git a/test/WireMock.Net.Aspire.Tests/WireMockServerArgumentsTests.cs b/test/WireMock.Net.Aspire.Tests/WireMockServerArgumentsTests.cs index 15a4ca3c0..60571ab5f 100644 --- a/test/WireMock.Net.Aspire.Tests/WireMockServerArgumentsTests.cs +++ b/test/WireMock.Net.Aspire.Tests/WireMockServerArgumentsTests.cs @@ -13,7 +13,7 @@ public void DefaultValues_ShouldBeSetCorrectly() var args = new WireMockServerArguments(); // Assert - args.HttpPort.Should().BeNull(); + args.HttpPorts.Should().BeEmpty(); args.AdminUsername.Should().BeNull(); args.AdminPassword.Should().BeNull(); args.ReadStaticMappings.Should().BeFalse(); diff --git a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs index 88ea28a05..8a2d61744 100644 --- a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs +++ b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using FluentAssertions; using Moq; +using WireMock.Util; namespace WireMock.Net.Aspire.Tests; @@ -40,7 +41,21 @@ public void AddWireMock_WithInvalidPort_ShouldThrowArgumentOutOfRangeException() } [Fact] - public void AddWireMock() + public void AddWireMock_WithInvalidAdditionalUrls_ShouldThrowArgumentException() + { + // Arrange + string[] invalidUrls = { "err" }; + var builder = Mock.Of(); + + // Act + Action act = () => builder.AddWireMock("ValidName", invalidUrls); + + // Assert + act.Should().Throw().WithMessage("The URL 'err' is not valid."); + } + + [Fact] + public void AddWireMockWithPort() { // Arrange var name = $"apiservice{Guid.NewGuid()}"; @@ -65,7 +80,7 @@ public void AddWireMock() ReadStaticMappings = true, WatchStaticMappings = false, MappingsPath = null, - HttpPort = port + HttpPorts = [port] }); wiremock.Resource.Annotations.Should().HaveCount(6); @@ -90,9 +105,90 @@ public void AddWireMock() )); wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); - wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); + wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); + } + + [Fact] + public void AddWireMockWithAdditionalUrls() + { + // Arrange + var name = $"apiservice{Guid.NewGuid()}"; + var freePorts = PortUtils.FindFreeTcpPorts(2).ToList(); + string[] additionalUrls = { $"http://*:{freePorts[0]}", $"grpc://*:{freePorts[1]}" }; + const string username = "admin"; + const string password = "test"; + var builder = DistributedApplication.CreateBuilder(); + + // Act + var wiremock = builder + .AddWireMock(name, additionalUrls) + .WithAdminUserNameAndPassword(username, password) + .WithReadStaticMappings(); + + // Assert + wiremock.Resource.Should().NotBeNull(); + wiremock.Resource.Name.Should().Be(name); + wiremock.Resource.Arguments.Should().BeEquivalentTo(new WireMockServerArguments + { + AdminPassword = password, + AdminUsername = username, + ReadStaticMappings = true, + WatchStaticMappings = false, + MappingsPath = null, + HttpPorts = freePorts, + AdditionalUrls = additionalUrls.ToList() + }); + wiremock.Resource.Annotations.Should().HaveCount(9); + + var containerImageAnnotation = wiremock.Resource.Annotations.OfType().FirstOrDefault(); + containerImageAnnotation.Should().BeEquivalentTo(new ContainerImageAnnotation + { + Image = "sheyenrath/wiremock.net-alpine", + Registry = null, + Tag = "latest" + }); + + var endpointAnnotations = wiremock.Resource.Annotations.OfType().ToArray(); + endpointAnnotations.Should().HaveCount(3); + var endpointAnnotationForHttp80 = endpointAnnotations[0]; + endpointAnnotationForHttp80.Should().BeEquivalentTo(new EndpointAnnotation( + protocol: ProtocolType.Tcp, + uriScheme: "http", + transport: null, + name: null, + port: null, + targetPort: 80, + isExternal: null, + isProxied: true + )); + var endpointAnnotationForHttpFreePort = endpointAnnotations[1]; + endpointAnnotationForHttpFreePort.Should().BeEquivalentTo(new EndpointAnnotation( + protocol: ProtocolType.Tcp, + uriScheme: "http", + transport: null, + name: $"http-{freePorts[0]}", + port: freePorts[0], + targetPort: freePorts[0], + isExternal: null, + isProxied: true + )); + + var endpointAnnotationForGrpcFreePort = endpointAnnotations[2]; + endpointAnnotationForGrpcFreePort.Should().BeEquivalentTo(new EndpointAnnotation( + protocol: ProtocolType.Tcp, + uriScheme: "grpc", + transport: null, + name: $"grpc-{freePorts[1]}", + port: freePorts[1], + targetPort: freePorts[1], + isExternal: null, + isProxied: true + )); + + wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); + wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); wiremock.Resource.Annotations.OfType().FirstOrDefault().Should().NotBeNull(); } } \ No newline at end of file diff --git a/test/WireMock.Net.Tests/Testcontainers/TestcontainersTests.Grpc.cs b/test/WireMock.Net.Tests/Testcontainers/TestcontainersTests.Grpc.cs index 12b903b2b..0afa831f2 100644 --- a/test/WireMock.Net.Tests/Testcontainers/TestcontainersTests.Grpc.cs +++ b/test/WireMock.Net.Tests/Testcontainers/TestcontainersTests.Grpc.cs @@ -56,7 +56,7 @@ public async Task WireMockContainer_Build_Grpc_TestPortsAndUrls1() var grpcPort = wireMockContainer.GetMappedPublicPort(9090); grpcPort.Should().BeGreaterThan(0); - var grpcUrl = wireMockContainer.GetMappedPublicUrl(80); + var grpcUrl = wireMockContainer.GetMappedPublicUrl(9090); grpcUrl.Should().StartWith("http://"); var adminClient = wireMockContainer.CreateWireMockAdminClient(); @@ -149,6 +149,18 @@ public async Task WireMockContainer_Build_Grpc_ProtoDefinitionAtServerLevel_Usin await StopAsync(wireMockContainer); } + [Fact] + public async Task WireMockContainer_Build_Grpc_ProtoDefinitionAtServerLevel_UsingGrpcGeneratedClient_AndWithWatchStaticMappings() + { + var wireMockContainer = await Given_WireMockContainerWithProtoDefinitionAtServerLevelWithWatchStaticMappingsIsStartedForHttpAndGrpcAsync(); + + var reply = await When_GrpcClient_Calls_SayHelloAsync(wireMockContainer); + + Then_ReplyMessage_Should_BeCorrect(reply); + + await StopAsync(wireMockContainer); + } + private static async Task Given_WireMockContainerIsStartedForHttpAndGrpcAsync() { var wireMockContainer = new WireMockContainerBuilder() @@ -172,6 +184,19 @@ private static async Task Given_WireMockContainerWithProtoDef return wireMockContainer; } + private static async Task Given_WireMockContainerWithProtoDefinitionAtServerLevelWithWatchStaticMappingsIsStartedForHttpAndGrpcAsync() + { + var wireMockContainer = new WireMockContainerBuilder() + .AddUrl("grpc://*:9090") + .AddProtoDefinition("my-greeter", ReadFile("greet.proto")) + .WithMappings(Path.Combine(Directory.GetCurrentDirectory(), "__admin", "mappings")) + .Build(); + + await wireMockContainer.StartAsync(); + + return wireMockContainer; + } + private static async Task Given_ProtoBufMappingIsAddedViaAdminInterfaceAsync(WireMockContainer wireMockContainer, string filename) { var mappingsJson = ReadFile(filename); diff --git a/test/WireMock.Net.Tests/Util/PortUtilsTests.cs b/test/WireMock.Net.Tests/Util/PortUtilsTests.cs index 90b7777fe..2915e75f0 100644 --- a/test/WireMock.Net.Tests/Util/PortUtilsTests.cs +++ b/test/WireMock.Net.Tests/Util/PortUtilsTests.cs @@ -15,13 +15,13 @@ public void PortUtils_TryExtract_InvalidUrl_Returns_False() var url = "test"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeFalse(); isHttps.Should().BeFalse(); isGrpc.Should().BeFalse(); - proto.Should().BeNull(); + scheme.Should().BeNull(); host.Should().BeNull(); port.Should().Be(default(int)); } @@ -33,13 +33,13 @@ public void PortUtils_TryExtract_UrlIsMissingPort_Returns_False() var url = "http://0.0.0.0"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeFalse(); isHttps.Should().BeFalse(); isGrpc.Should().BeFalse(); - proto.Should().BeNull(); + scheme.Should().BeNull(); host.Should().BeNull(); port.Should().Be(default(int)); } @@ -51,13 +51,13 @@ public void PortUtils_TryExtract_Http_Returns_True() var url = "http://wiremock.net:1234"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeTrue(); isHttps.Should().BeFalse(); isGrpc.Should().BeFalse(); - proto.Should().Be("http"); + scheme.Should().Be("http"); host.Should().Be("wiremock.net"); port.Should().Be(1234); } @@ -69,13 +69,13 @@ public void PortUtils_TryExtract_Https_Returns_True() var url = "https://wiremock.net:5000"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeTrue(); isHttps.Should().BeTrue(); isGrpc.Should().BeFalse(); - proto.Should().Be("https"); + scheme.Should().Be("https"); host.Should().Be("wiremock.net"); port.Should().Be(5000); } @@ -87,13 +87,13 @@ public void PortUtils_TryExtract_Grpc_Returns_True() var url = "grpc://wiremock.net:1234"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeTrue(); isHttps.Should().BeFalse(); isGrpc.Should().BeTrue(); - proto.Should().Be("grpc"); + scheme.Should().Be("grpc"); host.Should().Be("wiremock.net"); port.Should().Be(1234); } @@ -105,13 +105,13 @@ public void PortUtils_TryExtract_Https0_0_0_0_Returns_True() var url = "https://0.0.0.0:5000"; // Act - var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var proto, out var host, out var port); + var result = PortUtils.TryExtract(url, out var isHttps, out var isGrpc, out var scheme, out var host, out var port); // Assert result.Should().BeTrue(); isHttps.Should().BeTrue(); isGrpc.Should().BeFalse(); - proto.Should().Be("https"); + scheme.Should().Be("https"); host.Should().Be("0.0.0.0"); port.Should().Be(5000); }