diff --git a/Aspire.sln b/Aspire.sln index b9e012682bd..29a1ad40c41 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -152,7 +152,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.RabbitMQ.Client", "s EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.RabbitMQ.Client.Tests", "tests\Aspire.RabbitMQ.Client.Tests\Aspire.RabbitMQ.Client.Tests.csproj", "{165411FE-755E-4869-A756-F87F455860AC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject.WorkerA", "tests\testproject\TestProject.WorkerA\TestProject.WorkerA.csproj", "{6472D59F-7C04-43DE-AD33-9F20BE3804BF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestProject.WorkerA", "tests\testproject\TestProject.WorkerA\TestProject.WorkerA.csproj", "{6472D59F-7C04-43DE-AD33-9F20BE3804BF}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MySqlConnector", "src\Components\Aspire.MySqlConnector\Aspire.MySqlConnector.csproj", "{CA283D7F-EB95-4353-B196-C409965D2B42}" EndProject @@ -160,6 +160,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MySqlConnector.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestProject.IntegrationServiceA", "tests\testproject\TestProject.IntegrationServiceA\TestProject.IntegrationServiceA.csproj", "{DCF2D47A-921A-4900-B5B2-CF97B3531CE8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Oracle.ManagedDataAccess.Core", "src\Components\Aspire.Oracle.ManagedDataAccess.Core\Aspire.Oracle.ManagedDataAccess.Core.csproj", "{B13DF73F-A22F-4811-861B-4BC464A0D9F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Oracle.ManagedDataAccess.Core.Tests", "tests\Aspire.Oracle.ManagedDataAccess.Core.Tests\Aspire.Oracle.ManagedDataAccess.Core.Tests.csproj", "{D696C06F-AEFE-429D-A97A-AF3F4D4847D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -414,6 +418,10 @@ Global {165411FE-755E-4869-A756-F87F455860AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {165411FE-755E-4869-A756-F87F455860AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {165411FE-755E-4869-A756-F87F455860AC}.Release|Any CPU.Build.0 = Release|Any CPU + {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Release|Any CPU.Build.0 = Release|Any CPU {CA283D7F-EB95-4353-B196-C409965D2B42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CA283D7F-EB95-4353-B196-C409965D2B42}.Debug|Any CPU.Build.0 = Debug|Any CPU {CA283D7F-EB95-4353-B196-C409965D2B42}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -426,10 +434,14 @@ Global {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Release|Any CPU.Build.0 = Release|Any CPU - {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6472D59F-7C04-43DE-AD33-9F20BE3804BF}.Release|Any CPU.Build.0 = Release|Any CPU + {B13DF73F-A22F-4811-861B-4BC464A0D9F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B13DF73F-A22F-4811-861B-4BC464A0D9F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B13DF73F-A22F-4811-861B-4BC464A0D9F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B13DF73F-A22F-4811-861B-4BC464A0D9F3}.Release|Any CPU.Build.0 = Release|Any CPU + {D696C06F-AEFE-429D-A97A-AF3F4D4847D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D696C06F-AEFE-429D-A97A-AF3F4D4847D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D696C06F-AEFE-429D-A97A-AF3F4D4847D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D696C06F-AEFE-429D-A97A-AF3F4D4847D7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -501,10 +513,12 @@ Global {A84C4EE3-2601-4804-BCDC-E9948E164A22} = {A68BA1A5-1604-433D-9778-DC0199831C2A} {4D8A92AB-4E77-4965-AD8E-8E206DCE66A4} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {165411FE-755E-4869-A756-F87F455860AC} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {6472D59F-7C04-43DE-AD33-9F20BE3804BF} = {975F6F41-B455-451D-A312-098DE4A167B6} {CA283D7F-EB95-4353-B196-C409965D2B42} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {C8079F06-304F-49B1-A0C1-45AA3782A923} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {DCF2D47A-921A-4900-B5B2-CF97B3531CE8} = {975F6F41-B455-451D-A312-098DE4A167B6} - {6472D59F-7C04-43DE-AD33-9F20BE3804BF} = {975F6F41-B455-451D-A312-098DE4A167B6} + {B13DF73F-A22F-4811-861B-4BC464A0D9F3} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {D696C06F-AEFE-429D-A97A-AF3F4D4847D7} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/Directory.Packages.props b/Directory.Packages.props index 521516e0e83..a577f439900 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,7 @@ + @@ -74,6 +75,7 @@ + @@ -93,6 +95,7 @@ + diff --git a/src/Aspire.Hosting/Oracle/IOracleDatabaseResource.cs b/src/Aspire.Hosting/Oracle/IOracleDatabaseResource.cs new file mode 100644 index 00000000000..1dedeedc73a --- /dev/null +++ b/src/Aspire.Hosting/Oracle/IOracleDatabaseResource.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Oracle Database resource that requires a connection string. +/// +public interface IOracleDatabaseResource : IResourceWithConnectionString +{ +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs new file mode 100644 index 00000000000..08c1f6e9ef8 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Text.Json; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Oracle Database resources to an . +/// +public static class OracleDatabaseBuilderExtensions +{ + private const string PasswordEnvVarName = "ORACLE_DATABASE_PASSWORD"; + + /// + /// Adds a Oracle Database container to the application model. The default image is "database/free" and the tag is "latest". + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port for Oracle Database. + /// The password for the Oracle Database container. Defaults to a random password. + /// A reference to the . + public static IResourceBuilder AddOracleDatabaseContainer(this IDistributedApplicationBuilder builder, string name, int? port = null, string? password = null) + { + password = password ?? Guid.NewGuid().ToString("N"); + var oracleDatabaseContainer = new OracleDatabaseContainerResource(name, password); + return builder.AddResource(oracleDatabaseContainer) + .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteOracleDatabaseContainerToManifest)) + .WithAnnotation(new ServiceBindingAnnotation(ProtocolType.Tcp, port: port, containerPort: 1521)) + .WithAnnotation(new ContainerImageAnnotation { Image = "database/free", Tag = "latest", Registry = "container-registry.oracle.com" }) + .WithEnvironment(PasswordEnvVarName, () => oracleDatabaseContainer.Password); + } + + /// + /// Adds a Oracle Database connection to the application model. Connection strings can also be read from the connection string section of the configuration using the name of the resource. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The Oracle Database connection string (optional). + /// A reference to the . + public static IResourceBuilder AddOracleDatabaseConnection(this IDistributedApplicationBuilder builder, string name, string? connectionString = null) + { + var oracleDatabaseConnection = new OracleDatabaseConnectionResource(name, connectionString); + + return builder.AddResource(oracleDatabaseConnection) + .WithAnnotation(new ManifestPublishingCallbackAnnotation((json) => WriteOracleDatabaseConnectionToManifest(json, oracleDatabaseConnection))); + } + + /// + /// Adds a Oracle Database database to the application model. + /// + /// The Oracle Database server resource builder. + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// A reference to the . + public static IResourceBuilder AddDatabase(this IResourceBuilder builder, string name) + { + var oracleDatabase = new OracleDatabaseResource(name, builder.Resource); + return builder.ApplicationBuilder.AddResource(oracleDatabase) + .WithAnnotation(new ManifestPublishingCallbackAnnotation( + (json) => WriteOracleDatabaseToManifest(json, oracleDatabase))); + } + + private static void WriteOracleDatabaseConnectionToManifest(Utf8JsonWriter jsonWriter, OracleDatabaseConnectionResource oracleDatabaseConnection) + { + jsonWriter.WriteString("type", "oracle.connection.v0"); + jsonWriter.WriteString("connectionString", oracleDatabaseConnection.GetConnectionString()); + } + + private static void WriteOracleDatabaseContainerToManifest(Utf8JsonWriter jsonWriter) + { + jsonWriter.WriteString("type", "oracle.server.v0"); + } + + private static void WriteOracleDatabaseToManifest(Utf8JsonWriter json, OracleDatabaseResource oracleDatabase) + { + json.WriteString("type", "oracle.database.v0"); + json.WriteString("parent", oracleDatabase.Parent.Name); + } +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseConnectionResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseConnectionResource.cs new file mode 100644 index 00000000000..12bce173052 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseConnectionResource.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database connection. +/// +/// The name of the resource. +/// The Oracle Database connection string. +public class OracleDatabaseConnectionResource(string name, string? connectionString) : Resource(name), IOracleDatabaseResource +{ + private readonly string? _connectionString = connectionString; + + /// + /// Gets the connection string for the Oracle Database server. + /// + /// The specified connection string. + public string? GetConnectionString() => _connectionString; +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs new file mode 100644 index 00000000000..c54ba95e9a4 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database container. +/// +/// The name of the resource. +/// The Oracle Database server password. +public class OracleDatabaseContainerResource(string name, string password) : ContainerResource(name), IOracleDatabaseResource +{ + public string Password { get; } = password; + + /// + /// Gets the connection string for the Oracle Database server. + /// + /// A connection string for the Oracle Database server in the form "user id=system;password=password;data source=localhost:port". + public string? GetConnectionString() + { + if (!this.TryGetAllocatedEndPoints(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Expected allocated endpoints!"); + } + + var allocatedEndpoint = allocatedEndpoints.Single(); // We should only have one endpoint for Oracle Database. + + var connectionString = $"user id=system;password={Password};data source={allocatedEndpoint.Address}:{allocatedEndpoint.Port}"; + return connectionString; + } +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs new file mode 100644 index 00000000000..a258de32d77 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database database. This is a child resource of a . +/// +/// The name of the resource. +/// The Oracle Database server resource associated with this database. +public class OracleDatabaseResource(string name, OracleDatabaseContainerResource oracleContainer) : Resource(name), IOracleDatabaseResource, IResourceWithParent +{ + public OracleDatabaseContainerResource Parent { get; } = oracleContainer; + + /// + /// Gets the connection string for the Oracle Database. + /// + /// A connection string for the Oracle Database. + public string? GetConnectionString() + { + if (Parent.GetConnectionString() is { } connectionString) + { + return $"{connectionString}/{Name}"; + } + else + { + throw new DistributedApplicationException("Parent resource connection string was null."); + } + } +} diff --git a/src/Components/Aspire.Oracle.ManagedDataAccess.Core/Aspire.Oracle.ManagedDataAccess.Core.csproj b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/Aspire.Oracle.ManagedDataAccess.Core.csproj new file mode 100644 index 00000000000..0a0578f29f7 --- /dev/null +++ b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/Aspire.Oracle.ManagedDataAccess.Core.csproj @@ -0,0 +1,24 @@ + + + + $(NetCurrent) + true + $(ComponentDatabasePackageTags) oracle odp managed data access core sql + A Oracle® client that integrates with Aspire, including health checks, metrics, logging, and telemetry. + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Oracle.ManagedDataAccess.Core/AspireOracleManagedDataAccessCoreExtensions.cs b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/AspireOracleManagedDataAccessCoreExtensions.cs new file mode 100644 index 00000000000..5f1ab2a5cc2 --- /dev/null +++ b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/AspireOracleManagedDataAccessCoreExtensions.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Oracle.ManagedDataAccess.Core; +using HealthChecks.Oracle; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Oracle.ManagedDataAccess.Client; +using Oracle.ManagedDataAccess.OpenTelemetry; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for connecting Oracle database with Oracle.ManagedDataAccess.Core client +/// +public static class AspireOracleManagedDataAccessCoreExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Oracle:ManagedDataAccess:Core"; + + /// + /// Registers service for connecting Oracle database with Oracle.ManagedDataAccess.Core client. + /// Configures health check and telemetry for the Oracle.ManagedDataAccess.Core client. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:Oracle:ManagedDataAccess:Core" section. + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided. + public static void AddOracleManagedDataAccessCore(this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null) + => AddOracleManagedDataAccessCore(builder, DefaultConfigSectionName, configureSettings, connectionName, serviceKey: null); + + /// + /// Registers as a keyed service for given for connecting Oracle database with Oracle.ManagedDataAccess.Core client. + /// Configures health check and telemetry for the Npgsql client. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:Oracle:ManagedDataAccess:Core:{name}" section. + /// Thrown when or is null. + /// Thrown if mandatory is empty. + /// Thrown when mandatory is not provided. + public static void AddKeyedOracleManagedDataAccessCore(this IHostApplicationBuilder builder, string name, Action? configureSettings = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + AddOracleManagedDataAccessCore(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddOracleManagedDataAccessCore(IHostApplicationBuilder builder, string configurationSectionName, + Action? configureSettings, string connectionName, object? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + OracleManagedDataAccessCoreSettings settings = new(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + builder.RegisterOracleManagedDataAccessCoreServices(settings, configurationSectionName, connectionName, serviceKey); + + if (settings.HealthChecks) + { + builder.TryAddHealthCheck(new HealthCheckRegistration( + serviceKey is null ? "OracleManagedDataAccessCore" : $"OracleManagedDataAccessCore_{connectionName}", + sp => new OracleHealthCheck(new OracleHealthCheckOptions() + { + ConnectionString = settings.ConnectionString ?? string.Empty + }), + failureStatus: default, + tags: default, + timeout: default)); + } + + if (settings.Tracing) + { + builder.Services.AddOpenTelemetry() + .WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder.AddOracleDataProviderInstrumentation(o => + { + o.EnableConnectionLevelAttributes = true; + o.RecordException = true; + o.InstrumentOracleDataReaderRead = true; + o.SetDbStatementForText = true; + }) + .AddSource("Oracle.ManagedDataAccess.Core"); + }); + } + } + + private static void RegisterOracleManagedDataAccessCoreServices(this IHostApplicationBuilder builder, OracleManagedDataAccessCoreSettings settings, string configurationSectionName, string connectionName, object? serviceKey) + { + if (serviceKey is null) + { + // delay validating the ConnectionString until the DataSource is requested. This ensures an exception doesn't happen until a Logger is established. + builder.Services.AddScoped(serviceProvider => + { + ValidateConnection(); + + return new OracleConnection(settings.ConnectionString); + }); + } + else + { + builder.Services.AddKeyedScoped(serviceKey, (serviceProvider, key) => + { + ValidateConnection(); + + return new OracleConnection(settings.ConnectionString); + }); + } + + void ValidateConnection() + { + if (string.IsNullOrEmpty(settings.ConnectionString)) + { + throw new InvalidOperationException($"ConnectionString is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'ConnectionString' key in '{configurationSectionName}' configuration section."); + } + } + } +} diff --git a/src/Components/Aspire.Oracle.ManagedDataAccess.Core/ConfigurationSchema.json b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/ConfigurationSchema.json new file mode 100644 index 00000000000..8b05f3fea98 --- /dev/null +++ b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/ConfigurationSchema.json @@ -0,0 +1,39 @@ +{ + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Oracle": { + "type": "object", + "properties": { + "ManagedDataAccess": { + "type": "object", + "properties": { + "Core": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string of the Postgre SQL database to connect to." + }, + "HealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the database health check is enabled or not.", + "default": true + }, + "Tracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.", + "default": true + } + } + } + } + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.Oracle.ManagedDataAccess.Core/OracleManagedDataAccessCoreSettings.cs b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/OracleManagedDataAccessCoreSettings.cs new file mode 100644 index 00000000000..e0f45e415a9 --- /dev/null +++ b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/OracleManagedDataAccessCoreSettings.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Oracle.ManagedDataAccess.Core ; + +/// +/// Provides the client configuration settings for connecting to a PostgreSQL database using Npgsql. +/// +public sealed class OracleManagedDataAccessCoreSettings +{ + /// + /// The connection string of the PostgreSQL database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the database health check is enabled or not. + /// Enabled by default. + /// + public bool HealthChecks { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the Open Telemetry tracing is enabled or not. + /// Enabled by default. + /// + public bool Tracing { get; set; } = true; +} diff --git a/src/Components/Aspire.Oracle.ManagedDataAccess.Core/README.md b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/README.md new file mode 100644 index 00000000000..c90fcdffcf0 --- /dev/null +++ b/src/Components/Aspire.Oracle.ManagedDataAccess.Core/README.md @@ -0,0 +1,111 @@ +# Aspire.Oracle.ManagedDataAccess.Core library + +Registers [OracleConnection](https://docs.oracle.com/en/database/oracle/oracle-database/23/odpnt/OracleConnectionClass.html) in the DI container for connecting Oracle® database. Enables corresponding health check and telemetry. + +## Getting started + +### Prerequisites + +- Oracle database and connection string for accessing the database. + +### Install the package + +Install the .NET Aspire Oracle Managed Data Access Core library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Oracle.ManagedDataAccess.Core +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddOracleManagedDataAccessCore` extension method to register a `OracleConnection` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddOracleManagedDataAccessCore("oracledb"); +``` + +You can then retrieve the `OracleConnection` instance using dependency injection. For example, to retrieve the connection from a Web API controller: + +```csharp +private readonly OracleConnection _connection; + +public ProductsController(OracleConnection connection) +{ + _connection = connection; +} +``` + +## Configuration + +The .NET Aspire Oracle Managed Data Access Core component provides multiple options to configure the database connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddOracleManagedDataAccessCore()`: + +```csharp +builder.AddOracleManagedDataAccessCore("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "user id=system;password=password;data source=localhost:port/freepdb1" + } +} +``` + +### Use configuration providers + +The .NET Aspire Oracle Managed Data Access Core component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `OracleManagedDataAccessCoreSettings` from configuration by using the `Aspire:Oracle:ManagedDataAccess:Core` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Oracle": { + "ManagedDataAccess": { + "Core": { + "HealthChecks": true, + "Tracing": true + } + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp + builder.AddOracleManagedDataAccessCore("oracledb", settings => settings.HealthChecks = false); +``` + +## AppHost extensions + +In your AppHost project, register a Oracle Database container and consume the connection using the following methods: + +```csharp +var oracledb = builder.AddOracleDatabaseContainer("orcl").AddDatabase("freepdb1"); + +var myService = builder.AddProject() + .WithReference(oracledb); +``` + +The `WithReference` method configures a connection in the `MyService` project named `oracledb`. In the _Program.cs_ file of `MyService`, the database connection can be consumed using: + +```csharp +builder.AddOracleManagedDataAccessCore("oracledb"); +``` + +## Additional documentation + +* https://github.com/oracle/dotnet-db-samples/tree/master +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 427a1ac1c66..643561a789f 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -21,6 +21,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | StackExchange.Redis.OutputCaching | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ❌ | ✅ | | RabbitMQ | ✅ | ✅ | ✅ | ✅ | | | ❌ | ✅ | | MySqlConnector | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Oracle.ManagedDataAccess.Core | ✅ | | ✅ | ✅ | | ✅ | | ✅ | Nomenclature used in the table above: diff --git a/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/Aspire.Oracle.ManagedDataAccess.Core.Tests.csproj b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/Aspire.Oracle.ManagedDataAccess.Core.Tests.csproj new file mode 100644 index 00000000000..8ae8792523e --- /dev/null +++ b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/Aspire.Oracle.ManagedDataAccess.Core.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(NetCurrent) + + + + + + + + diff --git a/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/AspireOracleManagedDataAccessCoreExtensionsTests.cs b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/AspireOracleManagedDataAccessCoreExtensionsTests.cs new file mode 100644 index 00000000000..6abf5b8fee9 --- /dev/null +++ b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/AspireOracleManagedDataAccessCoreExtensionsTests.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Oracle.ManagedDataAccess.Client; +using Xunit; + +namespace Aspire.Oracle.ManagedDataAccess.Core.Tests; + +public class AspireOracleManagedDataAccessCoreExtensionsTests +{ + private const string ConnectionString = "user id=system;password=password;data source=localhost:port/freepdb1"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsFromConnectionStringsCorrectly(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:orcl", ConnectionString) + ]); + + if (useKeyed) + { + builder.AddKeyedOracleManagedDataAccessCore("orcl"); + } + else + { + builder.AddOracleManagedDataAccessCore("orcl"); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("orcl") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionStringCanBeSetInCode(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:orcl", "unused") + ]); + + static void SetConnectionString(OracleManagedDataAccessCoreSettings settings) => settings.ConnectionString = ConnectionString; + if (useKeyed) + { + builder.AddKeyedOracleManagedDataAccessCore("orcl", SetConnectionString); + } + else + { + builder.AddOracleManagedDataAccessCore("orcl", SetConnectionString); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("orcl") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.ConnectionString); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", connection.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionNameWinsOverConfigSection(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var key = useKeyed ? "orcl" : null; + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Oracle:ManagedDataAccess:Core", key, "ConnectionString"), "unused"), + new KeyValuePair("ConnectionStrings:orcl", ConnectionString) + ]); + + if (useKeyed) + { + builder.AddKeyedOracleManagedDataAccessCore("orcl"); + } + else + { + builder.AddOracleManagedDataAccessCore("orcl"); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("orcl") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.ConnectionString); + // the connection string from config should not be used since it was found in ConnectionStrings + Assert.DoesNotContain("unused", connection.ConnectionString); + } +} diff --git a/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConfigurationTests.cs b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..9c05a3a8701 --- /dev/null +++ b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConfigurationTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Oracle.ManagedDataAccess.Core.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() + => Assert.Null(new OracleManagedDataAccessCoreSettings().ConnectionString); + + [Fact] + public void HealthCheckIsEnabledByDefault() + => Assert.True(new OracleManagedDataAccessCoreSettings().HealthChecks); + + [Fact] + public void TracingIsEnabledByDefault() + => Assert.True(new OracleManagedDataAccessCoreSettings().Tracing); +} diff --git a/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConformanceTests.cs b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..ad66354cdb7 --- /dev/null +++ b/tests/Aspire.Oracle.ManagedDataAccess.Core.Tests/ConformanceTests.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Oracle.ManagedDataAccess.Client; +using Xunit; + +namespace Aspire.Oracle.ManagedDataAccess.Core.Tests; + +public class ConformanceTests : ConformanceTests +{ + private const string ConnectionSting = "user id=system;password=password;data source=localhost:port/freepdb1"; + + private static readonly Lazy s_canConnectToServer = new(GetCanConnect); + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Scoped; + + protected override string ActivitySourceName => "Oracle.ManagedDataAccess.Core"; + + protected override string[] RequiredLogCategories => []; + + protected override bool SupportsKeyedRegistrations => true; + + protected override bool CanConnectToServer => s_canConnectToServer.Value; + + protected override string JsonSchemaPath => "src/Components/Aspire.Oracle.ManagedDataAccess.Core/ConfigurationSchema.json"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Oracle": { + "ManagedDataAccess":{ + "Core": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "HealthChecks": false, + "Tracing": true + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Oracle": { "ManagedDataAccess": { "Core": { "Tracing": 0 }}}}}""", "Value is \"integer\" but should be \"boolean\""), + ("""{"Aspire": { "Oracle": { "ManagedDataAccess": { "Core": { "ConnectionString": "Con", "HealthChecks": "false"}}}}}""", "Value is \"string\" but should be \"boolean\"") + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new KeyValuePair(CreateConfigKey("Aspire:Oracle:ManagedDataAccess:Core", key, "ConnectionString"), ConnectionSting) + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddOracleManagedDataAccessCore("orcl", configure); + } + else + { + builder.AddKeyedOracleManagedDataAccessCore(key, configure); + } + } + + protected override void SetHealthCheck(OracleManagedDataAccessCoreSettings options, bool enabled) + => options.HealthChecks = enabled; + + protected override void SetTracing(OracleManagedDataAccessCoreSettings options, bool enabled) + => options.Tracing = enabled; + + protected override void SetMetrics(OracleManagedDataAccessCoreSettings options, bool enabled) {} + + protected override void TriggerActivity(OracleConnection connection) + { + connection.Open(); + using OracleCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM DUAL"; + command.ExecuteScalar(); + } + + [Theory] + [InlineData(null)] + [InlineData("key")] + public void ConnectionCanBeResolved(string? key) + { + using IHost host = CreateHostWithComponent(key: key); + + OracleConnection? oracleConnection = Resolve(); + + Assert.NotNull(oracleConnection); + + T? Resolve() => key is null ? host.Services.GetService() : host.Services.GetKeyedService(key); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource_Keyed() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: "key")).Dispose(); + } + + private static bool GetCanConnect() + { + OracleConnection connection = new(ConnectionSting); + OracleCommand? cmd = null; + + try + { + connection.Open(); + } + catch (Exception) + { + return false; + } + finally + { + cmd?.Dispose(); + connection.Dispose(); + } + + return true; + } +}