Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,51 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class MySqlHealthCheckBuilderExtensions
{
private const string NAME = "mysql";
internal const string HEALTH_QUERY = "SELECT 1;";

/// <summary>
/// Add a health check for MySQL databases.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="dataSourceFactory">An optional factory to create the <see cref="MySqlDataSource"/>. By default, one will be retrieved from the service collection.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
/// The <see cref="HealthStatus"/> that should be reported when the health check fails. Optional. If <c>null</c> then
/// the default status of <see cref="HealthStatus.Unhealthy"/> will be reported.
/// </param>
/// <param name="tags">A list of tags that can be used to filter sets of health checks. Optional.</param>
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
/// <returns>The specified <paramref name="builder"/>.</returns>
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
Func<IServiceProvider, MySqlDataSource>? dataSourceFactory = null,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
IEnumerable<string>? tags = default,
TimeSpan? timeout = default)
{
return builder.Add(new HealthCheckRegistration(
name ?? NAME,
sp => new MySqlHealthCheck(new()
{
DataSource = dataSourceFactory?.Invoke(sp) ?? sp.GetRequiredService<MySqlDataSource>(),
CommandText = healthQuery,
Configure = configure,
}),
failureStatus,
tags,
timeout));
}

/// <summary>
/// Add a health check for MySQL databases.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="connectionString">The MySQL connection string to be used.</param>
/// <param name="healthQuery">The query to be executed.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
Expand All @@ -30,7 +67,7 @@ public static class MySqlHealthCheckBuilderExtensions
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
string connectionString,
string healthQuery = HEALTH_QUERY,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
Expand All @@ -45,7 +82,7 @@ public static IHealthChecksBuilder AddMySql(
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="connectionStringFactory">A factory to build the MySQL connection string to use.</param>
/// <param name="healthQuery">The query to be executed.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
Expand All @@ -58,7 +95,7 @@ public static IHealthChecksBuilder AddMySql(
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
Func<IServiceProvider, string> connectionStringFactory,
string healthQuery = HEALTH_QUERY,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
Expand Down
2 changes: 1 addition & 1 deletion src/HealthChecks.MySql/HealthChecks.MySql.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="MySqlConnector" Version="2.2.7" />
<PackageReference Include="MySqlConnector" Version="2.3.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.9" />
</ItemGroup>

Expand Down
33 changes: 24 additions & 9 deletions src/HealthChecks.MySql/MySqlHealthCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ public class MySqlHealthCheck : IHealthCheck

public MySqlHealthCheck(MySqlHealthCheckOptions options)
{
Guard.ThrowIfNull(options.ConnectionString, true);
Guard.ThrowIfNull(options.CommandText, true);
Guard.ThrowIfNull(options);
if (options.DataSource is null && options.ConnectionString is null)
throw new InvalidOperationException("One of options.DataSource or options.ConnectionString must be specified.");
if (options.DataSource is not null && options.ConnectionString is not null)
throw new InvalidOperationException("Only one of options.DataSource or options.ConnectionString must be specified.");
_options = options;
Comment on lines +15 to 20

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need these checks anymore, as the public ctors ensure that the state is always valid.

Suggested change
Guard.ThrowIfNull(options);
if (options.DataSource is null && options.ConnectionString is null)
throw new InvalidOperationException("One of options.DataSource or options.ConnectionString must be specified.");
if (options.DataSource is not null && options.ConnectionString is not null)
throw new InvalidOperationException("Only one of options.DataSource or options.ConnectionString must be specified.");
_options = options;
_options = Guard.ThrowIfNull(options);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason I can't apply this suggestion. I am going to merge it now since it's not blocking

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

Expand All @@ -22,18 +25,30 @@ public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context
{
try
{
using var connection = new MySqlConnection(_options.ConnectionString);
using var connection = _options.DataSource is not null ?
_options.DataSource.CreateConnection() :
new MySqlConnection(_options.ConnectionString);

_options.Configure?.Invoke(connection);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);

using var command = connection.CreateCommand();
command.CommandText = _options.CommandText;
object? result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (_options.CommandText is { } commandText)
{
using var command = connection.CreateCommand();
command.CommandText = _options.CommandText;
object? result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);

return _options.HealthCheckResultBuilder == null
? HealthCheckResult.Healthy()
: _options.HealthCheckResultBuilder(result);
return _options.HealthCheckResultBuilder == null
? HealthCheckResult.Healthy()
: _options.HealthCheckResultBuilder(result);
}
else
{
var success = await connection.PingAsync(cancellationToken).ConfigureAwait(false);
return _options.HealthCheckResultBuilder is null
? (success ? HealthCheckResult.Healthy() : new HealthCheckResult(context.Registration.FailureStatus)) :
_options.HealthCheckResultBuilder(success);
}
}
catch (Exception ex)
{
Expand Down
12 changes: 8 additions & 4 deletions src/HealthChecks.MySql/MySqlHealthCheckOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MySqlConnector;

Expand All @@ -10,14 +9,19 @@ namespace HealthChecks.MySql;
public class MySqlHealthCheckOptions
{
/// <summary>
/// The MySQL connection string to be used.
/// The MySQL data source to be used. This is the preferred way to specify the MySQL server to be checked.
/// </summary>
public string ConnectionString { get; set; } = null!;
public MySqlDataSource? DataSource { get; set; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing a DataSource can lead to issues similar to the ones I've fixed in #2045 for PostgreSQL. IMO we should make it internal.

Suggested change
public MySqlDataSource? DataSource { get; set; }
internal MySqlDataSource? DataSource { get; set; }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you worried that this could lead to API misuse because someone could create a new MySqlDataSource just to set this property?

I'm more worried that without this property, someone would be forced to set the ConnectionString property, which would be a worse outcome. Making this internal would also make it impossible for an external client to initialize this object properly (i.e., with a data source), making the AddMySql overload that takes it a "pit of failure".

I'm happy to make this internal if we also make AddMySql(this IHealthChecksBuilder builder, MySqlHealthCheckOptions options, ...) [Obsolete] because it doesn't allow one to set to set the data source. Thoughts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about it for a while and experimenting with Npgsql in #2116 I got to the following conclusions:

Let's make the parameterless MySqlHealthCheckOptions ctor internal and add two pubic ctors: one that accepts ConnectionString and one that accepts DataSource.
Moreover, let's make the setters for these properties internal.

This allows us to ensure that every instance of this type is valid: it has either the connection string or the data source. Never both.

This is the only change I would like to make before merging this PR.

@bgrainger thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me; I'll update this PR with that pattern soon.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the parameterless MySqlHealthCheckOptions ctor internal ... Moreover, let's make the setters for these properties internal.

When developing this, I found that I didn't need a parameterless constructor nor to set the properties after construction. Thus, the MySqlHealthCheckOptions type has been updated to have just two public constructors and private setters for all properties.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When developing this, I found that I didn't need a parameterless constructor nor to set the properties after construction. Thus, the MySqlHealthCheckOptions type has been updated to have just two public constructors and private setters for all properties.

Great! 👍


/// <summary>
/// The MySQL connection string to be used, if <see cref="DataSource"/> isn't set.
/// </summary>
public string? ConnectionString { get; set; }

/// <summary>
/// The query to be executed.
/// </summary>
public string CommandText { get; set; } = MySqlHealthCheckBuilderExtensions.HEALTH_QUERY;
public string? CommandText { get; set; }

/// <summary>
/// An optional action executed before the connection is opened in the health check.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using MySqlConnector;

namespace HealthChecks.MySql.Tests.DependencyInjection;

public class mysql_registration_should
Expand All @@ -18,6 +20,7 @@ public void add_health_check_when_properly_configured()
registration.Name.ShouldBe("mysql");
check.ShouldBeOfType<MySqlHealthCheck>();
}

[Fact]
public void add_named_health_check_when_properly_configured()
{
Expand All @@ -34,4 +37,22 @@ public void add_named_health_check_when_properly_configured()
registration.Name.ShouldBe("my-mysql-group");
check.ShouldBeOfType<MySqlHealthCheck>();
}

[Fact]
public void add_health_check_for_data_source()
{
var services = new ServiceCollection();
services
.AddMySqlDataSource("Server=example")
.AddHealthChecks().AddMySql();

using var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<HealthCheckServiceOptions>>();

var registration = options.Value.Registrations.First();
var check = registration.Factory(serviceProvider);

registration.Name.ShouldBe("mysql");
check.ShouldBeOfType<MySqlHealthCheck>();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
using System.Net;
using MySqlConnector;

namespace HealthChecks.MySql.Tests.Functional;

public class mysql_healthcheck_should
{
[Fact]
public async Task be_healthy_when_mysql_server_is_available()
public async Task be_healthy_when_mysql_server_is_available_using_data_source()
{
var connectionString = "server=localhost;port=3306;database=information_schema;uid=root;password=Password12!";

var webHostBuilder = new WebHostBuilder()
.ConfigureServices(services =>
{
services
.AddMySqlDataSource(connectionString)
Comment thread
bgrainger marked this conversation as resolved.
.AddHealthChecks().AddMySql(tags: new string[] { "mysql" });
})
.Configure(app =>
{
app.UseHealthChecks("/health", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("mysql")
});
});

using var server = new TestServer(webHostBuilder);

using var response = await server.CreateRequest("/health").GetAsync().ConfigureAwait(false);

response.StatusCode.ShouldBe(HttpStatusCode.OK);
}

[Fact]
public async Task be_healthy_when_mysql_server_is_available_using_connection_string()
{
var connectionString = "server=localhost;port=3306;database=information_schema;uid=root;password=Password12!";

Expand Down
4 changes: 4 additions & 0 deletions test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="MySqlConnector.DependencyInjection" Version="2.3.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\HealthChecks.MySql\HealthChecks.MySql.csproj" />
</ItemGroup>
Expand Down