diff --git a/.editorconfig b/.editorconfig index 34af5a8..48b9a2c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -651,4 +651,8 @@ dotnet_diagnostic.CS8981.severity = none [src/Dataverse/**] dotnet_diagnostic.CA1510.severity = none dotnet_diagnostic.CA1307.severity = none -dotnet_diagnostic.MA0009.severity = none # With data that comes from Dataverse we will not worry about Denial of service attacks \ No newline at end of file +dotnet_diagnostic.MA0009.severity = none # With data that comes from Dataverse we will not worry about Denial of service attacks + +[src/Dataverse/AdhocJobs/Jobs/**] +dotnet_diagnostic.MA0048.severity = none # Job files use top-level statements which generate a Program class, not matching file name +dotnet_diagnostic.CA1812.severity = none # Same reason - generated Program class appears never instantiated \ No newline at end of file diff --git a/XrmBedrock.slnx b/XrmBedrock.slnx index 307215d..be82227 100644 --- a/XrmBedrock.slnx +++ b/XrmBedrock.slnx @@ -10,7 +10,7 @@ - + diff --git a/src/Dataverse/AdhocJobs/AdhocJobs.csproj b/src/Dataverse/AdhocJobs/AdhocJobs.csproj new file mode 100644 index 0000000..22bd39f --- /dev/null +++ b/src/Dataverse/AdhocJobs/AdhocJobs.csproj @@ -0,0 +1,14 @@ + + + Exe + FileBasedProgram + + + + + + + + + + diff --git a/src/Dataverse/AdhocJobs/JobContext.cs b/src/Dataverse/AdhocJobs/JobContext.cs new file mode 100644 index 0000000..6c26228 --- /dev/null +++ b/src/Dataverse/AdhocJobs/JobContext.cs @@ -0,0 +1,10 @@ +using Microsoft.Xrm.Sdk; +using SharedContext.Dao; + +namespace AdhocJobs; + +public record JobContext( + IOrganizationService OrgService, + DataverseAccessObject Dao, + Uri DataverseUri, + IServiceProvider Services); diff --git a/src/Dataverse/AdhocJobs/JobSetup.cs b/src/Dataverse/AdhocJobs/JobSetup.cs new file mode 100644 index 0000000..edac973 --- /dev/null +++ b/src/Dataverse/AdhocJobs/JobSetup.cs @@ -0,0 +1,48 @@ +using Azure.Identity; +using DataverseConnection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Xrm.Sdk; +using SharedContext.Dao; + +namespace AdhocJobs; + +public static class JobSetup +{ +#pragma warning disable S1075 // URIs should not be hardcoded + private const string DefaultUri = "https://YOUR-ENV.crm4.dynamics.com"; +#pragma warning restore S1075 // URIs should not be hardcoded + + public static JobContext Initialize(string[] args) + { + return Initialize(args, null); + } + + public static JobContext Initialize(string[] args, Action? configureServices) + { + ArgumentNullException.ThrowIfNull(args); + var dataverseUri = args.Length > 0 ? new Uri(args[0]) : new Uri(DefaultUri); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddConsole()); + services.AddDataverseWithOrganizationServices(options => + { + // Swap with whatever fits you. This uses the token from az login + options.TokenCredential = new AzureCliCredential(); + options.DataverseUrl = dataverseUri.ToString(); + }); + + configureServices?.Invoke(services); + + var sp = services.BuildServiceProvider(); + var orgService = sp.GetRequiredService(); + var logger = sp.GetRequiredService().CreateLogger("BatchJob"); + var dao = new DataverseAccessObject(orgService, logger); + + var ctx = new JobContext(orgService, dao, dataverseUri, sp); + + logger.LogInformation($"Connected to {ctx.DataverseUri}"); + + return ctx; + } +} diff --git a/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs b/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs new file mode 100644 index 0000000..9d6844b --- /dev/null +++ b/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs @@ -0,0 +1,14 @@ +#:package DataverseConnection@1.1.1 +#:package Azure.Identity@1.13.2 +#:project ../AdhocJobs.csproj +#:property PublishAot=false + +using AdhocJobs; + +var ctx = JobSetup.Initialize(args); + +var accounts = ctx.Dao.RetrieveList(xrm => xrm.AccountSet) + .Select(a => new { a.AccountId, a.Name }) + .ToList(); + +Console.WriteLine($"Found {accounts.Count} accounts"); diff --git a/src/Dataverse/ConsoleJobs/App.config b/src/Dataverse/ConsoleJobs/App.config deleted file mode 100644 index 4a99e16..0000000 --- a/src/Dataverse/ConsoleJobs/App.config +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj b/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj deleted file mode 100644 index 65e54cb..0000000 --- a/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net462 - enable - enable - Exe - AnyCPU - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs b/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs deleted file mode 100644 index 1486394..0000000 --- a/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.ComponentModel; -using System.Configuration; -using System.Globalization; -using System.Text; - -namespace ConsoleJobs.Helpers; - -internal static class CsvHelper -{ - public static void WriteToCsv(string folderPath, string fileName, IEnumerable items, bool appendToFileIfExists = false) - where T : class, new() - { - if (string.IsNullOrWhiteSpace(folderPath)) - throw new ArgumentException("File path is empty", nameof(folderPath)); - - if (string.IsNullOrWhiteSpace(fileName)) - throw new ArgumentException("File name is empty", nameof(fileName)); - - var filePath = Path.Combine(folderPath, fileName); - - FileInfo fi = new FileInfo(filePath); - Directory.CreateDirectory(fi.DirectoryName); - - using StreamWriter writer = new StreamWriter(filePath, appendToFileIfExists, Encoding.UTF8); - - if (!appendToFileIfExists) - { - writer.WriteLine(GetCsvHeader()); - } - - foreach (var item in items) - { - writer.WriteLine(RowToCsv(item)); - } - } - - public static List ReadFromCsv(string filePath, bool hasHeader = true) - where T : class, new() - { - var returnList = new List(); - - using (StreamReader reader = new StreamReader(filePath, Encoding.UTF8)) - { - var res = reader.ReadLine(); - if (hasHeader && res != null) - { - res = reader.ReadLine(); - } - - while (res != null) - { - returnList.Add(RowFromCsv(res)); - res = reader.ReadLine(); - } - } - - return returnList; - } - - public static string GetCsvHeader() - where T : class, new() - { - var properties = typeof(T).GetProperties(); - var strings = new string[properties.Length]; - for (int i = 0; i < properties.Length; ++i) - { - if (properties[i].GetCustomAttributes(typeof(HeaderAttribute), true).FirstOrDefault() is HeaderAttribute headerNameAttribute) - { - strings[i] = headerNameAttribute.Name; - } - else - { - strings[i] = properties[i].Name; - } - } - - return string.Join(ConfigurationManager.AppSettings["CsvSeparator"], strings); - } - - public static string RowToCsv(T item) - where T : class, new() - { - var properties = typeof(T).GetProperties(); - var strings = new string[properties.Length]; - for (int i = 0; i < properties.Length; ++i) - { - strings[i] = properties[i].GetValue(item)?.ToString() ?? string.Empty; - } - - return string.Join(ConfigurationManager.AppSettings["CsvSeparator"], strings); - } - - public static T RowFromCsv(string item) - where T : class, new() - { - var attributeStrings = item.Split(ConfigurationManager.AppSettings["CsvSeparator"].ToArray()); - var properties = typeof(T).GetProperties(); - - if (attributeStrings.Length != properties.Length) - throw new InvalidDataException("Number of columns in csv does not match number of properties in class"); - - T returnval = new T(); - for (int i = 0; i < attributeStrings.Length; ++i) - { - var property = properties[i]; - object value = FromString(property.PropertyType, attributeStrings[i]); - property.SetValue(returnval, value); - } - - return returnval; - } - - private static object FromString(Type type, string s) - { - switch (Type.GetTypeCode(type)) - { - case TypeCode.Int32: - if (type.IsEnum) - return Enum.Parse(type, s); - - return int.Parse(s, CultureInfo.InvariantCulture); - case TypeCode.Int64: - return long.Parse(s, CultureInfo.InvariantCulture); - case TypeCode.String: - return s; - case TypeCode.Boolean: - return bool.Parse(s); - case TypeCode.DateTime: - return DateTime.Parse(s, CultureInfo.InvariantCulture); - case TypeCode.Double: - return double.Parse(s, CultureInfo.InvariantCulture); - case TypeCode.Decimal: - return decimal.Parse(s, CultureInfo.InvariantCulture); - case TypeCode.Object: - if (Nullable.GetUnderlyingType(type) != null && !string.IsNullOrEmpty(s)) - return FromString(Nullable.GetUnderlyingType(type), s); - - if (Guid.TryParse(s, out Guid res)) - return res; - - return TypeDescriptor.GetConverter(type).ConvertFromString(null, CultureInfo.InvariantCulture, s); - - default: - return TypeDescriptor.GetConverter(type).ConvertFromString(null, CultureInfo.InvariantCulture, s); - } - } -} diff --git a/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs b/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs deleted file mode 100644 index 008c8d3..0000000 --- a/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ConsoleJobs.Helpers; - -[AttributeUsage(AttributeTargets.Property)] -internal sealed class HeaderAttribute : Attribute -{ - public string Name { get; internal set; } - - public HeaderAttribute(string name) - { - Name = name; - } -} diff --git a/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs b/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs deleted file mode 100644 index 63bd459..0000000 --- a/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ConsoleJobs.Helpers; -using ConsoleJobs.Setup; -using Microsoft.Extensions.Logging; - -namespace ConsoleJobs.Jobs; - -#pragma warning disable CA1518 // It's an example, it's OK that its not referenced -#pragma warning disable CA1812 // It's an example, it's OK that its not referenced - -internal sealed class ExampleJob : IJob -#pragma warning restore CA1812 -#pragma warning restore CA1518 -{ - private sealed class PrintableAccount - { - public Guid? Id { get; set; } - - public string? Name { get; set; } - } - - public void Run(EnvironmentConfig env) - { - var accounts = env.Dao.RetrieveList(xrm => xrm.AccountSet).Select(x => new PrintableAccount { Id = x.AccountId, Name = x.Name }); - - env.Tracing.LogInformation("Account count: {Count}", accounts.Count()); - - CsvHelper.WriteToCsv(env.CsvFolderPath, "ListOfAccounts.csv", accounts); - } -} diff --git a/src/Dataverse/ConsoleJobs/Jobs/IJob.cs b/src/Dataverse/ConsoleJobs/Jobs/IJob.cs deleted file mode 100644 index 1ecc681..0000000 --- a/src/Dataverse/ConsoleJobs/Jobs/IJob.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ConsoleJobs.Setup; - -namespace ConsoleJobs.Jobs; - -internal interface IJob -{ - void Run(EnvironmentConfig env); -} diff --git a/src/Dataverse/ConsoleJobs/Program.cs b/src/Dataverse/ConsoleJobs/Program.cs deleted file mode 100644 index 306ea16..0000000 --- a/src/Dataverse/ConsoleJobs/Program.cs +++ /dev/null @@ -1,90 +0,0 @@ -using ConsoleJobs.Jobs; -using ConsoleJobs.Setup; -using System.Configuration; -using Environment = ConsoleJobs.Setup.Environment; - -namespace ConsoleJobs; - -internal static class Program -{ - private static void Main() - { - var env = GetEnviromentFromConfig(); - IJob job = GetJobFromConfiguration(); // Used to get the job from the app config file - ExecuteJobOnEnvironment(env, job); - } - - /// - /// Gets the enviroment from app config. - /// - /// The Dataverse environment to execute the job on - private static Environment GetEnviromentFromConfig() - { - Environment result; - var envStr = ConfigurationManager.AppSettings["Environment"]; - if (string.IsNullOrWhiteSpace(envStr) || !Enum.TryParse(envStr, out result)) - throw new ConfigurationErrorsException("Environment not specified in App.config or could not be parsed as EnvironmentEnum"); - - return result; - } - - /// - /// Creates an instance of the IJob given the classname. - /// - /// The job to execute - private static IJob GetJobFromConfiguration() - { - var envStr = ConfigurationManager.AppSettings["JobClassName"]; - if (string.IsNullOrWhiteSpace(envStr)) - throw new ConfigurationErrorsException("JobClassName not specified in App.config"); - - Type t = Type.GetType(envStr); - return (IJob)Activator.CreateInstance(t); - } - - /// - /// Creates a connection to CRM by getting the clientid and secret from the app config. - /// - /// Which Dataverse environment to execute on - /// Returns the config with relevant properties for the job, including the Dao - private static EnvironmentConfig GetEnv(Environment env) - { - var newEnv = EnvironmentConfig.Create(env); - return newEnv; - } - - /// - /// Executes the job on the specified environment - /// - /// The environment the job should be executed on - /// The job to execute - private static void ExecuteJobOnEnvironment(Environment env, IJob job) - { - Console.WriteLine($"You are attempting to run {job.GetType().Name} on {env}.\nPress 'Y' to continue..."); - var keyPressed = Console.ReadKey(); - if (keyPressed.Key != ConsoleKey.Y) - { -#pragma warning disable CA1303 // Do not pass literals as localized parameters - Console.WriteLine("Aborted by user.\nExiting..."); -#pragma warning restore CA1303 // Do not pass literals as localized parameters - return; - } - - var environment = GetEnv(env); -#pragma warning disable CA1031 // Do not catch general exception types - try - { - job.Run(environment); - } - catch (Exception e) - { - Console.WriteLine(e.Message); - } -#pragma warning restore CA1031 // Do not catch general exception types - -#pragma warning disable CA1303 // Do not pass literals as localized parameters - Console.WriteLine("Program finished.\nPress any key to continue..."); -#pragma warning restore CA1303 // Do not pass literals as localized parameters - Console.ReadKey(); - } -} diff --git a/src/Dataverse/ConsoleJobs/README.md b/src/Dataverse/ConsoleJobs/README.md deleted file mode 100644 index 8ce0066..0000000 --- a/src/Dataverse/ConsoleJobs/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# ConsoleJobs -ConsoleJobs is a framework that streamlines the development of jobs/scripts that run against D365 using self-contained, single-configured jobs with CSV read/write capability. - -## Features -- **Self-contained jobs.** You write the entirety of your ConsoleJob in one file. -- **Centralized configuration.** All necessary configuration is in App.config. -- **Methods for reading and writing to CSV files.** Generic read and write methods are provided with minimal per-job setup. - -## Configuration -In App.config, define the following parameters: - -`Environment` The environment to run your ConsoleJob against. - -`JobClassName` The full name of the ConsoleJob to run. - -`DevEnv`, `TestEnv`, `UatEnv`, `ProdEnv`: URLs of your Dev, Test, UAT and Prod environments. - -`CsvSeparator` Separator to used for CSV files. Configured here for convenience, due to Excel's regional differences. - -`AuthType` The authentication type for connecting to Dynamics 365 (set to "OAuth"). - -`Username` The email address of a user that has access to the CRM-environment. - -`LoginPrompt` When to display the login prompt (set to "Always"). - -`AppId` The application ID for Azure AD authentication. [Supplied by Microsoft.](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/xrm-tooling/use-connection-strings-xrm-tooling-connect#connection-string-parameters) No need to change this. - -`RedirectUri` The redirect URI for OAuth authentication flow. [Supplied by Microsoft.](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/xrm-tooling/use-connection-strings-xrm-tooling-connect#connection-string-parameters) No need to change this. - -## Usage -1. Create your job as a class implementing the `IJob` interface in the /Jobs directory. -2. Set the `ConsoleJobs` project as your startup project in Visual Studio. -3. In the App.config, make sure to configure `JobClassName`, `Environment`, `DevEnv`, `TestEnv`, `UatEnv`, `ProdEnv` and `Username`. -4. Build and run the `ConsoleJobs` project to run your job. - -### Adding Additional Environments -To add additional environments, create an entry in App.config for it in the same fashion as the other environments, expand the `EnvironmentsEnum` and `GetUrlFromEnvironment()` in `EnvironmentConfig.cs` to include your new environment. \ No newline at end of file diff --git a/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs b/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs deleted file mode 100644 index 828850b..0000000 --- a/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ConsoleJobs.Setup; - -internal sealed class ConsoleLogger : ILogger -{ - public ConsoleLogger() - { - } - - public IDisposable? BeginScope(TState state) - where TState : notnull => default!; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (!IsEnabled(logLevel)) - return; - - if (formatter is null) - throw new ArgumentNullException(nameof(formatter)); - - Console.WriteLine($"[{eventId.Id,2}: {logLevel,-12}]"); - Console.WriteLine($"{formatter(state, exception)}"); - } -} diff --git a/src/Dataverse/ConsoleJobs/Setup/Environment.cs b/src/Dataverse/ConsoleJobs/Setup/Environment.cs deleted file mode 100644 index a89f0ab..0000000 --- a/src/Dataverse/ConsoleJobs/Setup/Environment.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ConsoleJobs.Setup; - -internal enum Environment -{ - Dev, - Test, - Uat, - Prod, -} diff --git a/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs b/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs deleted file mode 100644 index 0b37e26..0000000 --- a/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Xrm.Tooling.Connector; -using SharedContext.Dao; -using System.Configuration; - -namespace ConsoleJobs.Setup; - -internal sealed class EnvironmentConfig -{ - public Environment CurrentEnvironment { get; private set; } - - public DataverseAccessObject Dao { get; private set; } = null!; - - public ILogger Tracing { get; private set; } - - /// - /// By default set to the desktop folder. - /// - public string CsvFolderPath { get; private set; } = string.Empty; - - private EnvironmentConfig() - { - Tracing = new ConsoleLogger(); - } - - public static EnvironmentConfig Create(Environment env) - { - return new EnvironmentConfig - { - CurrentEnvironment = env, - Dao = GetDao(env), - CsvFolderPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop), - }; - } - - private static DataverseAccessObject GetDao(Environment env) - { - var connString = GetDataverseConnectionstring(env); -#pragma warning disable CA2000 // Dispose objects before losing scope - var orgService = GetOrgService(connString); -#pragma warning restore CA2000 // Dispose objects before losing scope - var logger = new ConsoleLogger(); - return new DataverseAccessObject(orgService, logger); - } - - private static string GetDataverseConnectionstring(Environment env) - { - var auth = ConfigurationManager.AppSettings["AuthType"]; - var url = GetUrlFromEnvironment(env); - var username = ConfigurationManager.AppSettings["Username"]; - var loginprompt = ConfigurationManager.AppSettings["LoginPrompt"]; - var appId = ConfigurationManager.AppSettings["AppId"]; - var redirectUri = ConfigurationManager.AppSettings["RedirectUri"]; - - return $"AuthType={auth};url={url};Username={username};LoginPrompt={loginprompt};AppId={appId};RedirectUri={redirectUri};"; - } - - private static string GetUrlFromEnvironment(Environment env) - { - return env switch - { - Environment.Dev => ConfigurationManager.AppSettings["DevEnv"], - Environment.Test => ConfigurationManager.AppSettings["TestEnv"], - Environment.Uat => ConfigurationManager.AppSettings["UatEnv"], - Environment.Prod => ConfigurationManager.AppSettings["ProdEnv"], - _ => throw new ArgumentException("Environment not supported", nameof(env)), - }; - } - - private static CrmServiceClient GetOrgService(string dataverseConnectionstring) - { - var client = new CrmServiceClient(dataverseConnectionstring); - if (client.LastCrmException != null) -#pragma warning disable CA2201 // Do not raise reserved exception types -#pragma warning disable S112 // General or reserved exceptions should never be thrown - throw new Exception($"Connection to D365 fails with exception {client.LastCrmException.Message} Error: {client.LastCrmError}", client.LastCrmException); -#pragma warning restore S112 // General or reserved exceptions should never be thrown -#pragma warning restore CA2201 // Do not raise reserved exception types - return client; - } -}