diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..01328d7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build + +on: + pull_request: + types: [assigned, opened, synchronize, reopened] + +jobs: + test: + runs-on: windows-latest + name: Testing + steps: + - name: Checkout code base + uses: actions/checkout@v2 + + - name: Add nuget Source + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: nuget sources Add -Name Github -Source https://nuget.pkg.github.com/thygesteffensen/index.json -UserName thygesteffensen -Password $env:GH_TOKEN + + - name: Set Github nuget API + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: nuget setapikey $env:GH_TOKEN -Source https://nuget.pkg.github.com/thygesteffensen/index.json + + - name: Run tests + run: dotnet test --verbosity normal + + - name: Run tests + run: dotnet test --verbosity normal + + build: + runs-on: windows-latest + name: Building + steps: + - name: Checkout code base + uses: actions/checkout@v2 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v1 + + - name: Add nuget Source + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: nuget sources Add -Name Github -Source https://nuget.pkg.github.com/thygesteffensen/index.json -UserName thygesteffensen -Password $env:GH_TOKEN + + - name: Set Github nuget API + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: nuget setapikey $env:GH_TOKEN -Source https://nuget.pkg.github.com/thygesteffensen/index.json + + - name: Run tests + run: dotnet test --verbosity normal + + - name: Restore NuGet packages + run: nuget restore PAMU_CDS.sln + + - name: Build solution + run: msbuild /p:OutputPath=../build /p:Configuration=Release /p:RestorePackages=false + + - name: Archive build to artifacts + uses: actions/upload-artifact@v2 + with: + name: build + path: | + build/PAMU_CDS.dll + retention-days: 5 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8b92127..3960d39 100644 --- a/.gitignore +++ b/.gitignore @@ -430,4 +430,8 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser -# End of https://www.toptal.com/developers/gitignore/api/rider,csharp \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/rider,csharp +PAMU_CDS.sln.DotSettings +PAMU_CDS/Properties/ +build/*.dll +build/*.config diff --git a/PAMU_CDS/Actions/CreateRecordAction.cs b/PAMU_CDS/Actions/CreateRecordAction.cs index 8d8e52e..46b6c76 100644 --- a/PAMU_CDS/Actions/CreateRecordAction.cs +++ b/PAMU_CDS/Actions/CreateRecordAction.cs @@ -11,6 +11,8 @@ namespace PAMU_CDS.Actions { public class CreateRecordAction : OpenApiConnectionActionExecutorBase { + public const string OperationId = "CreateRecord"; + private readonly IOrganizationService _organizationService; private readonly IState _state; diff --git a/PAMU_CDS/Actions/DeleteRecordAction.cs b/PAMU_CDS/Actions/DeleteRecordAction.cs index 27a41b5..2aa1e5f 100644 --- a/PAMU_CDS/Actions/DeleteRecordAction.cs +++ b/PAMU_CDS/Actions/DeleteRecordAction.cs @@ -9,6 +9,8 @@ namespace PAMU_CDS.Actions { public class DeleteRecordAction : OpenApiConnectionActionExecutorBase { + public const string OperationId = "CreateRecord"; + private readonly IOrganizationService _organizationService; public DeleteRecordAction(IExpressionEngine expressionEngine, IOrganizationService organizationService) : base( diff --git a/PAMU_CDS/Actions/DisAndAssociateEntitiesAction.cs b/PAMU_CDS/Actions/DisAndAssociateEntitiesAction.cs new file mode 100644 index 0000000..a17e3af --- /dev/null +++ b/PAMU_CDS/Actions/DisAndAssociateEntitiesAction.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; +using PAMU_CDS.Auxiliary; +using Parser.ExpressionParser; +using Parser.FlowParser.ActionExecutors; + +namespace PAMU_CDS.Actions +{ + public class DisAndAssociateEntitiesAction : OpenApiConnectionActionExecutorBase + { + private const string AssociateId = "AssociateEntities"; + private const string DisassociateId = "DisassociateEntities"; + public static readonly string[] OperationId = {AssociateId, DisassociateId}; + + private readonly IOrganizationService _organizationService; + + public DisAndAssociateEntitiesAction( + IExpressionEngine expressionEngine, + IOrganizationService organizationService) : base(expressionEngine) + { + _organizationService = organizationService ?? throw new ArgumentNullException(nameof(organizationService)); + } + + + public override Task Execute() + { + var entity = new Entity(); + entity = entity.CreateEntityFromParameters(Parameters); + + OrganizationRequest associateRequest; + + switch (Host.OperationId) + { + case AssociateId: + { + var relatedEntity = ExtractEntityReferenceFromOdataId("item/@odata.id"); + associateRequest = new AssociateRequest + { + Target = entity.ToEntityReference(), + Relationship = new Relationship(Parameters["associationEntityRelationship"].GetValue()), + RelatedEntities = new EntityReferenceCollection {relatedEntity} + }; + break; + } + case DisassociateId: + { + var relatedEntity = ExtractEntityReferenceFromOdataId("$id"); + + associateRequest = new DisassociateRequest + { + Target = entity.ToEntityReference(), + Relationship = new Relationship(Parameters["associationEntityRelationship"].GetValue()), + RelatedEntities = new EntityReferenceCollection {relatedEntity} + }; + break; + } + default: + throw new PowerAutomateException( + $"Action {nameof(DisAndAssociateEntitiesAction)} can only handle {AssociateId} and {DisassociateId} operations, not {Host.OperationId}."); + } + + try + { + // TODO: Figure out how this handle bad associations and error handling. + // assignees: thygesteffensen + _organizationService.Execute(associateRequest); + } + catch (InvalidPluginExecutionException) + { + // We need to do some experiments on how the error handling works. Take a look at one of your customers. + return Task.FromResult(new ActionResult {ActionStatus = ActionStatus.Failed}); + } + + return Task.FromResult(new ActionResult {ActionStatus = ActionStatus.Succeeded}); + } + + private EntityReference ExtractEntityReferenceFromOdataId(string itemKey) + { + // https://dglab6.crm4.dynamics.com/api/data/v9.1/contacts(8c711383-b933-eb11-a813-000d3ab11761) + + var oDataId = Parameters[itemKey].GetValue(); + var entityName = + oDataId.Substring(oDataId.LastIndexOf('/') + 1, oDataId.IndexOf('(') - oDataId.LastIndexOf('/') - 2); + var entityId = oDataId.Substring(oDataId.IndexOf('(') + 1, oDataId.IndexOf(')') - oDataId.IndexOf('(') - 1); + + return new EntityReference(entityName, new Guid(entityId)); + } + } +} \ No newline at end of file diff --git a/PAMU_CDS/Actions/GetItemAction.cs b/PAMU_CDS/Actions/GetItemAction.cs index 51916bd..602a0f2 100644 --- a/PAMU_CDS/Actions/GetItemAction.cs +++ b/PAMU_CDS/Actions/GetItemAction.cs @@ -16,6 +16,8 @@ namespace PAMU_CDS.Actions { public class GetItemAction : OpenApiConnectionActionExecutorBase { + public const string OperationId = "GetItem"; + private readonly IOrganizationService _organizationService; private readonly IState _state; private readonly ILogger _logger; diff --git a/PAMU_CDS/Actions/UpdateRecordAction.cs b/PAMU_CDS/Actions/UpdateRecordAction.cs index d5ac7e7..2d8de94 100644 --- a/PAMU_CDS/Actions/UpdateRecordAction.cs +++ b/PAMU_CDS/Actions/UpdateRecordAction.cs @@ -12,6 +12,8 @@ namespace PAMU_CDS.Actions { public class UpdateRecordAction : OpenApiConnectionActionExecutorBase { + public const string OperationId = "UpdateRecord"; + private readonly IOrganizationService _organizationService; private readonly IState _state; diff --git a/PAMU_CDS/CommonDataServiceCurrentEnvironment.cs b/PAMU_CDS/CommonDataServiceCurrentEnvironment.cs index e72bab1..f68198c 100644 --- a/PAMU_CDS/CommonDataServiceCurrentEnvironment.cs +++ b/PAMU_CDS/CommonDataServiceCurrentEnvironment.cs @@ -52,7 +52,7 @@ public void TriggerExtension( using var scope = sp.CreateScope(); var isp = scope.ServiceProvider; var state = sp.GetRequiredService(); - + state.AddTriggerOutputs(currentEntity.ToValueContainer()); var flowRunner = sp.GetRequiredService(); @@ -119,10 +119,20 @@ private static ServiceCollection BuildServiceCollection(IOrganizationService org services.AddFlowActionByApiIdAndOperationsName(apiId, new[] {"SubscribeWebhookTrigger"}); - services.AddFlowActionByApiIdAndOperationsName(apiId, new[] {"CreateRecord"}); - services.AddFlowActionByApiIdAndOperationsName(apiId, new[] {"UpdateRecord"}); - services.AddFlowActionByApiIdAndOperationsName(apiId, new[] {"DeleteRecord"}); - services.AddFlowActionByApiIdAndOperationsName(apiId, new[] {"GetItem"}); + services.AddFlowActionByApiIdAndOperationsName(apiId, + new[] {CreateRecordAction.OperationId}); + + services.AddFlowActionByApiIdAndOperationsName(apiId, + new[] {UpdateRecordAction.OperationId}); + + services.AddFlowActionByApiIdAndOperationsName(apiId, + new[] {DeleteRecordAction.OperationId}); + + services.AddFlowActionByApiIdAndOperationsName(apiId, + new[] {GetItemAction.OperationId}); + + services.AddFlowActionByApiIdAndOperationsName(apiId, + DisAndAssociateEntitiesAction.OperationId); // services.AddFlowActionByFlowType("ExecuteChangeset"); // services.AddFlowActionByFlowType("ListRecords"); // // services.AddFlowActionByFlowType<>("PerformBoundAction"); diff --git a/ReadMe.md b/ReadMe.md index 3b0a3c2..295f482 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -27,21 +27,6 @@ This is both a full featured mock and an example of how to use [Power Automate M This mock i build using [Power Automate Mockup](https://github.com/thygesteffensen/PowerAutomateMockup) as the flow engine and [XrmMockup](https://github.com/delegateas/XrmMockup) to mock the underlying Dynamics 365. -## Code style -The code is written using [Riders](https://www.jetbrains.com/help/rider/Settings_Code_Style_CSHARP.html) default C# code style. - -Commits are written in [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) style, the commit messages are used to determine the version and when to release a new version. The pipeline is hosted on Github and [Semantic Release](https://github.com/semantic-release/semantic-release) is used. - -## Installation - -Currently the project is still in alpha. To find the packages at nuget.com, you have to check 'Prerelease', before the nuget appears. - -You also need a modified version of [XrmMockup](https://github.com/delegateas/XrmMockup), you can find that [here]() - -## Tests - -Tests are located in the **Tests** project and they are written using Nunit as test framework. - ## How to use ### Introduction @@ -49,7 +34,7 @@ This is a fully featured mock for the CDS ce connector and it works OOB if you'r ### Getting Started -First of all, replace your XrmMockup dependency with the development version developed to this project. The development version is build on the latest version of XrmMockup +First of all, replace your XrmMockup dependency with the development version developed to this project. The development version is build on the latest version of XrmMockup. When configuring XrmMockup, add the following: ```c# @@ -74,6 +59,21 @@ Coming soon ### Asserting Actions Coming soon +## Code style +The code is written using [Riders](https://www.jetbrains.com/help/rider/Settings_Code_Style_CSHARP.html) default C# code style. + +Commits are written in [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) style, the commit messages are used to determine the version and when to release a new version. The pipeline is hosted on Github and [Semantic Release](https://github.com/semantic-release/semantic-release) is used. + +## Installation + +Currently the project is still in alpha. To find the packages at nuget.com, you have to check 'Prerelease', before the nuget appears. + +You also need a modified version of [XrmMockup](https://github.com/delegateas/XrmMockup), you can find that [here]() + +## Tests + +Tests are located in the **Tests** project and they are written using Nunit as test framework. + ## Contribute This is my bachelor project and I'm currently not accepting contributions until it have been handed in. Anyway, fell free to drop an issue with a suggestion or improvement. diff --git a/Test/Test.csproj b/Test/Test.csproj index f1292a8..7c264da 100644 --- a/Test/Test.csproj +++ b/Test/Test.csproj @@ -4,6 +4,8 @@ net48;net462 false + + true @@ -17,13 +19,13 @@ - + - - PreserveNewest - + + PreserveNewest + diff --git a/Test/UnitTest/DisAndAssociateEntitiesActionTest.cs b/Test/UnitTest/DisAndAssociateEntitiesActionTest.cs new file mode 100644 index 0000000..d455a32 --- /dev/null +++ b/Test/UnitTest/DisAndAssociateEntitiesActionTest.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; +using Moq; +using Newtonsoft.Json.Linq; +using PAMU_CDS.Actions; +using Parser.ExpressionParser; +using Parser.FlowParser.ActionExecutors; + +namespace Test.UnitTest +{ + [TestClass] + public class DisAndAssociateEntitiesActionTest + { + [TestMethod] + public async Task TestAssociateAction() + { + AssociateRequest associateRequest = null; + var accountId = Guid.NewGuid(); + var parentAccountId = Guid.NewGuid(); + var relationship = new Relationship("account_parent_account"); + + var orgServiceMock = new Mock(); + orgServiceMock.Setup(x => x.Execute(It.IsAny())) + .Callback(request => { associateRequest = (AssociateRequest) request; }); + + var expressionEngineMock = new Mock(); + expressionEngineMock.Setup(x => x.Parse(It.IsAny())).Returns(input => input); + + var associateActionExecutor = + new DisAndAssociateEntitiesAction(expressionEngineMock.Object, orgServiceMock.Object); + + var actionDescription = + "{\"type\":\"OpenApiConnection\"," + + "\"inputs\":{" + + "\"host\":{\"connectionName\":\"shared_commondataserviceforapps_1\",\"operationId\":\"AssociateEntities\",\"apiId\":\"/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps\"}," + + $"\"parameters\":{{\"entityName\":\"accounts\",\"recordId\":\"{accountId}\",\"associationEntityRelationship\":\"{relationship.SchemaName}\"," + + $"\"item/@odata.id\":\"https://contoso.crm4.dynamics.com/api/data/v9.1/accounts({parentAccountId})\"}}," + + "\"authentication\":\"@parameters('$authentication')\"}}"; + associateActionExecutor.InitializeActionExecutor("DeleteContact", JToken.Parse(actionDescription)); + + var response = await associateActionExecutor.Execute(); + + Assert.AreEqual(ActionStatus.Succeeded, response.ActionStatus); + Assert.AreEqual(true, response.ContinueExecution); + Assert.AreEqual(null, response.NextAction); + + orgServiceMock.Verify(x=> x.Execute(It.IsAny())); + + Assert.AreEqual(relationship.SchemaName, associateRequest.Relationship.SchemaName); + Assert.AreEqual(accountId, associateRequest.Target.Id); + Assert.AreEqual("account", associateRequest.Target.LogicalName); + Assert.AreEqual(1, associateRequest.RelatedEntities.Count); + Assert.AreEqual(parentAccountId, associateRequest.RelatedEntities[0].Id); + Assert.AreEqual("account", associateRequest.RelatedEntities[0].LogicalName); + } + + [TestMethod] + public async Task TestDisassociateAction() + { + DisassociateRequest disassociateRequest = null; + var accountId = Guid.NewGuid(); + var parentAccountId = Guid.NewGuid(); + var relationship = new Relationship("account_parent_account"); + + var orgServiceMock = new Mock(); + orgServiceMock.Setup(x => x.Execute(It.IsAny())) + .Callback(request => { disassociateRequest = (DisassociateRequest) request; }); + + var expressionEngineMock = new Mock(); + expressionEngineMock.Setup(x => x.Parse(It.IsAny())).Returns(input => input); + + var associateActionExecutor = + new DisAndAssociateEntitiesAction(expressionEngineMock.Object, orgServiceMock.Object); + + var actionDescription = + "{\"type\":\"OpenApiConnection\"," + + "\"inputs\":{" + + "\"host\":{\"connectionName\":\"shared_commondataserviceforapps_1\",\"operationId\":\"DisassociateEntities\",\"apiId\":\"/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps\"}," + + $"\"parameters\":{{\"entityName\":\"accounts\",\"recordId\":\"{accountId}\",\"associationEntityRelationship\":\"{relationship.SchemaName}\"," + + $"\"$id\":\"https://contoso.crm4.dynamics.com/api/data/v9.1/accounts({parentAccountId})\"}}," + + "\"authentication\":\"@parameters('$authentication')\"}}"; + associateActionExecutor.InitializeActionExecutor("DeleteContact", JToken.Parse(actionDescription)); + + var response = await associateActionExecutor.Execute(); + + Assert.AreEqual(ActionStatus.Succeeded, response.ActionStatus); + Assert.AreEqual(true, response.ContinueExecution); + Assert.AreEqual(null, response.NextAction); + + orgServiceMock.Verify(x=> x.Execute(It.IsAny())); + + Assert.AreEqual(relationship.SchemaName, disassociateRequest.Relationship.SchemaName); + Assert.AreEqual(accountId, disassociateRequest.Target.Id); + Assert.AreEqual("account", disassociateRequest.Target.LogicalName); + Assert.AreEqual(1, disassociateRequest.RelatedEntities.Count); + Assert.AreEqual(parentAccountId, disassociateRequest.RelatedEntities[0].Id); + Assert.AreEqual("account", disassociateRequest.RelatedEntities[0].LogicalName); + } + } +} \ No newline at end of file diff --git a/build/TestFlows/Pure_CDS_ce.json b/build/TestFlows/Pure_CDS_ce.json new file mode 100644 index 0000000..484e336 --- /dev/null +++ b/build/TestFlows/Pure_CDS_ce.json @@ -0,0 +1,69 @@ +{ + "name": "803e7e0d-a6a8-449d-8205-25d3e4e79377", + "id": "/providers/Microsoft.Flow/flows/803e7e0d-a6a8-449d-8205-25d3e4e79377", + "type": "Microsoft.Flow/flows", + "properties": { + "apiId": "/providers/Microsoft.PowerApps/apis/shared_logicflows", + "displayName": "When a contacts is created, set job title", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "$connections": { + "defaultValue": {}, + "type": "Object" + }, + "$authentication": { + "defaultValue": {}, + "type": "SecureObject" + } + }, + "triggers": { + "When_a_contact_is_created": { + "type": "OpenApiConnectionWebhook", + "inputs": { + "host": { + "connectionName": "shared_commondataserviceforapps", + "operationId": "SubscribeWebhookTrigger", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps" + }, + "parameters": { + "subscriptionRequest/message": 1, + "subscriptionRequest/entityname": "contact", + "subscriptionRequest/scope": 4 + }, + "authentication": "@parameters('$authentication')" + } + } + }, + "actions": { + "Update_a_record_-_Set_job_title_to_Technical_Supervisor": { + "runAfter": {}, + "type": "OpenApiConnection", + "inputs": { + "host": { + "connectionName": "shared_commondataserviceforapps", + "operationId": "UpdateRecord", + "apiId": "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps" + }, + "parameters": { + "entityName": "contacts", + "recordId": "@triggerOutputs()?['body/contactid']", + "item/jobtitle": "Technical Supervisor" + }, + "authentication": "@parameters('$authentication')" + } + } + }, + "outputs": {} + }, + "connectionReferences": { + "shared_commondataserviceforapps": { + "source": "Embedded", + "id": "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps", + "tier": "NotSpecified" + } + }, + "flowFailureAlertSubscribed": false + } +} \ No newline at end of file