Add Aspire Extension (#1109)

* WireMock.Net.Aspire

* .

* xxx

* nuget

* [CodeFactor] Apply fixes

* ut

* t

* **WireMock.Net.Aspire**

* .

* t

* .

* .

* .

* TESTS

* docker utils

* Install .NET Aspire workload

* 4

* 4!

* projects: '**/test/**/*.csproj'

* script: 'dotnet workload install aspire'

* projects: '**/test/**/*.csproj'

* coverage

* WithWatchStaticMappings

* Admin

* typo

* port

* fix

* .

* x

* ...

* wait

* readme

* x

* 2

* async

* <Version>0.0.1-preview-03</Version>

* ...

* fix aspire

* admin/pwd

* Install .NET Aspire workload

* 0.0.1-preview-04

* WaitForHealthAsync

* ...

* IsHealthyAsync

* .

* add eps

* name: 'Execute Aspire Tests'

* name: Install .NET Aspire workload

* .

* dotnet test

* remove duplicate

* .

* cc

* dotnet tool install --global coverlet.console

* -*

* merge

* /d:sonar.pullrequest.provider=github

* <Version>0.0.1-preview-05</Version>

* // Copyright © WireMock.Net

* .

---------

Co-authored-by: codefactor-io <support@codefactor.io>
This commit is contained in:
Stef Heyenrath
2024-07-27 18:53:59 +02:00
committed by GitHub
parent 69c829fae0
commit 4b12f3419f
70 changed files with 2849 additions and 31 deletions

View File

@@ -0,0 +1,96 @@
// Copyright © WireMock.Net
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RestEase;
using WireMock.Client;
// ReSharper disable once CheckNamespace
namespace Aspire.Hosting;
/// <summary>
/// Some WireMock.Net extension methods for working with <see cref="DistributedApplication"/>.
/// Based on https://github.com/dotnet/aspire/blob/main/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs
/// </summary>
public static class DistributedApplicationExtensions
{
/// <summary>
/// Create a RestEase Admin client which can be used to call the admin REST endpoint.
/// </summary>
/// <param name="app">The <see cref="DistributedApplication"/>.</param>
/// <param name="resourceName">The resourceName of the resource.</param>
/// <param name="endpointName">The resourceName of the endpoint on the resource to communicate with.</param>
/// <returns>A <see cref="IWireMockAdminApi"/></returns>
public static IWireMockAdminApi CreateWireMockAdminClient(this DistributedApplication app, string resourceName, string? endpointName = default)
{
ThrowIfNotStarted(app);
var (resource, endpointUri) = GetResourceAndEndpointUri(app, resourceName);
var api = RestClient.For<IWireMockAdminApi>(endpointUri);
if (resource.Arguments.HasBasicAuthentication)
{
api.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{resource.Arguments.AdminUsername}:{resource.Arguments.AdminPassword}")));
}
return api;
}
private static (WireMockServerResource WireMockServerResource, string EndpointUri) GetResourceAndEndpointUri(IHost app, string resourceName, string? endpointName = default)
{
var wireMockServerResource = GetWireMockServerResource(app, resourceName);
EndpointReference? endpoint;
if (!string.IsNullOrEmpty(endpointName))
{
endpoint = GetEndpointOrDefault(wireMockServerResource, endpointName);
}
else
{
endpoint = GetEndpointOrDefault(wireMockServerResource, "http") ?? GetEndpointOrDefault(wireMockServerResource, "https");
}
if (endpoint is null)
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Endpoint '{0}' for resource '{1}' not found.", endpointName, resourceName), nameof(endpointName));
}
return (wireMockServerResource, endpoint.Url);
}
private static WireMockServerResource GetWireMockServerResource(IHost app, string resourceName)
{
var applicationModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = applicationModel.Resources
.OfType<WireMockServerResource>()
.SingleOrDefault(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase));
if (resource is null)
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "WireMockServerResource with name '{0}' not found.", resourceName), nameof(resourceName));
}
return resource;
}
private static EndpointReference? GetEndpointOrDefault(IResourceWithEndpoints wireMockServerResource, string endpointName)
{
var reference = wireMockServerResource.GetEndpoint(endpointName);
return reference.IsAllocated ? reference : null;
}
private static void ThrowIfNotStarted(IHost app)
{
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
if (!lifetime.ApplicationStarted.IsCancellationRequested)
{
throw new InvalidOperationException("The application must be started before resolving endpoints or connection strings");
}
}
}

View File

@@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("WireMock.Net.Aspire.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e138ec44d93acac565953052636eb8d5e7e9f27ddb030590055cd1a0ab2069a5623f1f77ca907d78e0b37066ca0f6d63da7eecc3fcb65b76aa8ebeccf7ebe1d11264b8404cd9b1cbbf2c83f566e033b3e54129f6ef28daffff776ba7aebbc53c0d635ebad8f45f78eb3f7e0459023c218f003416e080f96a1a3c5ffeb56bee9e")]
// Needed for Moq in the UnitTest project
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]

View File

@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>0.0.1-preview-05</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Aspire extension to start a WireMock.Net server to stub an api.</Description>
<AssemblyTitle>WireMock.Net.Aspire</AssemblyTitle>
<Authors>Stef Heyenrath</Authors>
<TargetFramework>net8.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyName>WireMock.Net.Aspire</AssemblyName>
<PackageId>WireMock.Net.Aspire</PackageId>
<PackageTags>dotnet;aspire;wiremock;extension</PackageTags>
<ProjectGuid>{B6269AAC-170A-4346-8B9A-579DED3D9A12}</ProjectGuid>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
<CodeAnalysisRuleSet>../WireMock.Net/WireMock.Net.ruleset</CodeAnalysisRuleSet>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>../WireMock.Net/WireMock.Net.snk</AssemblyOriginatorKeyFile>
<PublicSign Condition=" '$(OS)' != 'Windows_NT' ">true</PublicSign>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIcon>WireMock.Net-LogoAspire.png</PackageIcon>
<ApplicationIcon>../../resources/WireMock.Net-LogoAspire.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<None Remove="../../resources/WireMock.Net-Logo.png" />
<None Include="../../resources/WireMock.Net-LogoAspire.png" Pack="true" PackagePath="" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WireMock.Net.RestClient\WireMock.Net.RestClient.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,112 @@
// Copyright © WireMock.Net
using System.Diagnostics.CodeAnalysis;
using WireMock.Client.Builders;
// ReSharper disable once CheckNamespace
namespace Aspire.Hosting;
/// <summary>
/// Represents the arguments required to configure and start a WireMock.Net Server.
/// </summary>
public class WireMockServerArguments
{
internal const int HttpContainerPort = 80;
/// <summary>
/// The default HTTP port where WireMock.Net is listening.
/// </summary>
public const int DefaultPort = 9091;
private const string DefaultLogger = "WireMockConsoleLogger";
/// <summary>
/// The HTTP port where WireMock.Net is listening.
/// If not defined, .NET Aspire automatically assigns a random port.
/// </summary>
public int? HttpPort { get; set; }
/// <summary>
/// The admin username.
/// </summary>
[MemberNotNullWhen(true, nameof(HasBasicAuthentication))]
public string? AdminUsername { get; set; }
/// <summary>
/// The admin password.
/// </summary>
[MemberNotNullWhen(true, nameof(HasBasicAuthentication))]
public string? AdminPassword { get; set; }
/// <summary>
/// Defines if the static mappings should be read at startup.
///
/// Default value is <c>false</c>.
/// </summary>
public bool ReadStaticMappings { get; set; }
/// <summary>
/// Watch the static mapping files + folder for changes when running.
///
/// Default value is <c>false</c>.
/// </summary>
public bool WithWatchStaticMappings { get; set; }
/// <summary>
/// Specifies the path for the (static) mapping json files.
/// </summary>
public string? MappingsPath { get; set; }
/// <summary>
/// Indicates whether the admin interface has Basic Authentication.
/// </summary>
public bool HasBasicAuthentication => !string.IsNullOrEmpty(AdminUsername) && !string.IsNullOrEmpty(AdminPassword);
/// <summary>
/// Optional delegate that will be invoked to configure the WireMock.Net resource using the <see cref="AdminApiMappingBuilder"/>.
/// </summary>
public Func<AdminApiMappingBuilder, Task>? ApiMappingBuilder { get; set; }
/// <summary>
/// Converts the current instance's properties to an array of command-line arguments for starting the WireMock.Net server.
/// </summary>
/// <returns>An array of strings representing the command-line arguments.</returns>
public string[] GetArgs()
{
var args = new Dictionary<string, string>();
Add(args, "--WireMockLogger", DefaultLogger);
if (HasBasicAuthentication)
{
Add(args, "--AdminUserName", AdminUsername!);
Add(args, "--AdminPassword", AdminPassword!);
}
if (ReadStaticMappings)
{
Add(args, "--ReadStaticMappings", "true");
}
if (WithWatchStaticMappings)
{
Add(args, "--ReadStaticMappings", "true");
Add(args, "--WatchStaticMappings", "true");
Add(args, "--WatchStaticMappingsInSubdirectories", "true");
}
return args
.SelectMany(k => new[] { k.Key, k.Value })
.ToArray();
}
private static void Add(IDictionary<string, string> args, string argument, string value)
{
args[argument] = value;
}
private static void Add(IDictionary<string, string> args, string argument, Func<string> action)
{
args[argument] = action();
}
}

View File

@@ -0,0 +1,162 @@
// Copyright © WireMock.Net
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Stef.Validation;
using WireMock.Client.Builders;
using WireMock.Net.Aspire;
// ReSharper disable once CheckNamespace
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding WireMock.Net Server resources to the application model.
/// </summary>
public static class WireMockServerBuilderExtensions
{
// Linux only (https://github.com/dotnet/aspire/issues/854)
private const string DefaultLinuxImage = "sheyenrath/wiremock.net-alpine";
private const string DefaultLinuxMappingsPath = "/app/__admin/mappings";
/// <summary>
/// Adds a WireMock.Net Server resource to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
/// <param name="port">The HTTP port for the WireMock Server.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> AddWireMock(this IDistributedApplicationBuilder builder, string name, int? port = null)
{
Guard.NotNull(builder);
Guard.NotNullOrWhiteSpace(name);
Guard.Condition(port, p => p is null or > 0 and <= ushort.MaxValue);
return builder.AddWireMock(name, callback =>
{
callback.HttpPort = port;
});
}
/// <summary>
/// Adds a WireMock.Net Server resource to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
/// <param name="arguments">The arguments to start the WireMock.Net Server.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> AddWireMock(this IDistributedApplicationBuilder builder, string name, WireMockServerArguments arguments)
{
Guard.NotNull(builder);
Guard.NotNullOrWhiteSpace(name);
Guard.NotNull(arguments);
var wireMockContainerResource = new WireMockServerResource(name, arguments);
var resourceBuilder = builder
.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);
if (!string.IsNullOrEmpty(arguments.MappingsPath))
{
resourceBuilder = resourceBuilder.WithBindMount(arguments.MappingsPath, DefaultLinuxMappingsPath);
}
resourceBuilder = resourceBuilder.WithArgs(ctx =>
{
foreach (var arg in arguments.GetArgs())
{
ctx.Args.Add(arg);
}
});
return resourceBuilder;
}
/// <summary>
/// Adds a WireMock.Net Server resource to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
/// <param name="callback">A callback that allows for setting the <see cref="WireMockServerArguments"/>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> AddWireMock(this IDistributedApplicationBuilder builder, string name, Action<WireMockServerArguments> callback)
{
Guard.NotNull(builder);
Guard.NotNullOrWhiteSpace(name);
Guard.NotNull(callback);
var arguments = new WireMockServerArguments();
callback(arguments);
return builder.AddWireMock(name, arguments);
}
/// <summary>
/// Defines if the static mappings should be read at startup.
///
/// Default set to <c>false</c>.
/// </summary>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> WithReadStaticMappings(this IResourceBuilder<WireMockServerResource> wiremock)
{
Guard.NotNull(wiremock).Resource.Arguments.ReadStaticMappings = true;
return wiremock;
}
/// <summary>
/// Watch the static mapping files + folder for changes when running.
///
/// Default set to <c>false</c>.
/// </summary>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> WithWatchStaticMappings(this IResourceBuilder<WireMockServerResource> wiremock)
{
Guard.NotNull(wiremock).Resource.Arguments.WithWatchStaticMappings = true;
return wiremock;
}
/// <summary>
/// Specifies the path for the (static) mapping json files.
/// </summary>
/// <param name="wiremock">The <see cref="IResourceBuilder{WireMockServerResource}"/>.</param>
/// <param name="mappingsPath">The local path.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> WithMappingsPath(this IResourceBuilder<WireMockServerResource> wiremock, string mappingsPath)
{
return Guard.NotNull(wiremock)
.WithBindMount(Guard.NotNullOrWhiteSpace(mappingsPath), DefaultLinuxMappingsPath);
}
/// <summary>
/// Set the admin username and password for accessing the admin interface from WireMock.Net via HTTP.
/// </summary>
/// <param name="wiremock">The <see cref="IResourceBuilder{WireMockServerResource}"/>.</param>
/// <param name="username">The admin username.</param>
/// <param name="password">The admin password.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{WireMockServerResource}"/>.</returns>
public static IResourceBuilder<WireMockServerResource> WithAdminUserNameAndPassword(this IResourceBuilder<WireMockServerResource> wiremock, string username, string password)
{
Guard.NotNull(wiremock);
wiremock.Resource.Arguments.AdminUsername = Guard.NotNull(username);
wiremock.Resource.Arguments.AdminPassword = Guard.NotNull(password);
return wiremock;
}
/// <summary>
/// Use WireMock Client's AdminApiMappingBuilder to configure the WireMock.Net resource.
/// </summary>
/// <param name="wiremock">The <see cref="IResourceBuilder{WireMockServerResource}"/>.</param>
/// <param name="configure">Delegate that will be invoked to configure the WireMock.Net resource.</param>
/// <returns></returns>
public static IResourceBuilder<WireMockServerResource> WithApiMappingBuilder(this IResourceBuilder<WireMockServerResource> wiremock, Func<AdminApiMappingBuilder, Task> configure)
{
Guard.NotNull(wiremock);
wiremock.ApplicationBuilder.Services.TryAddLifecycleHook<WireMockServerLifecycleHook>();
wiremock.Resource.Arguments.ApiMappingBuilder = configure;
return wiremock;
}
}

View File

@@ -0,0 +1,52 @@
// Copyright © WireMock.Net
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.Logging;
using RestEase;
using WireMock.Client;
using WireMock.Client.Extensions;
namespace WireMock.Net.Aspire;
internal class WireMockServerLifecycleHook(ResourceLoggerService loggerService) : IDistributedApplicationLifecycleHook
{
public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
{
var wireMockServerResources = appModel.Resources
.OfType<WireMockServerResource>()
.Where(resource => resource.Arguments.ApiMappingBuilder is not null)
.ToArray();
if (wireMockServerResources.Length == 0)
{
return;
}
foreach (var wireMockServerResource in wireMockServerResources)
{
var endpoint = wireMockServerResource.GetEndpoint();
if (endpoint.IsAllocated)
{
var adminApi = CreateWireMockAdminApi(wireMockServerResource);
var logger = loggerService.GetLogger(wireMockServerResource);
logger.LogInformation("Checking Health status from WireMock.Net");
await adminApi.WaitForHealthAsync(cancellationToken: cancellationToken);
logger.LogInformation("Calling ApiMappingBuilder to add mappings to WireMock.Net");
var mappingBuilder = adminApi.GetMappingBuilder();
await wireMockServerResource.Arguments.ApiMappingBuilder!.Invoke(mappingBuilder);
}
}
}
private static IWireMockAdminApi CreateWireMockAdminApi(WireMockServerResource resource)
{
var adminApi = RestClient.For<IWireMockAdminApi>(resource.GetEndpoint().Url);
return resource.Arguments.HasBasicAuthentication ?
adminApi.WithAuthorization(resource.Arguments.AdminUsername!, resource.Arguments.AdminPassword!) :
adminApi;
}
}

View File

@@ -0,0 +1,33 @@
// Copyright © WireMock.Net
using Stef.Validation;
// ReSharper disable once CheckNamespace
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// A resource that represents a WireMock.Net Server.
/// </summary>
public class WireMockServerResource : ContainerResource, IResourceWithServiceDiscovery
{
internal WireMockServerArguments Arguments { get; }
/// <summary>
/// Initializes a new instance of the <see cref="WireMockServerResource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="arguments">The arguments to start the WireMock.Net Server.</param>
public WireMockServerResource(string name, WireMockServerArguments arguments) : base(name)
{
Arguments = Guard.NotNull(arguments);
}
/// <summary>
/// Gets an endpoint reference.
/// </summary>
/// <returns>An <see cref="EndpointReference"/> object representing the endpoint reference.</returns>
public EndpointReference GetEndpoint()
{
return new EndpointReference(this, "http");
}
}

View File

@@ -2,8 +2,6 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using DotNet.Testcontainers.Containers;
using JetBrains.Annotations;
using RestEase;
@@ -103,4 +101,4 @@ public sealed class WireMockContainer : DockerContainer
}
private Uri GetPublicUri() => new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(ContainerPort)).Uri;
}
}