From 1c61896e460ca3da32b005cac51c086b86c1fc9d Mon Sep 17 00:00:00 2001 From: Luc Genetier Date: Mon, 17 Feb 2025 15:25:29 +0100 Subject: [PATCH] Add serialization of "items" less arrays --- .../Execution/FormulaValueSerializer.cs | 70 +++++++++++++++- .../PowerPlatformConnectorTests.cs | 82 +++++++++++++++++++ .../Swagger/Dataverse.json | 72 ++++++++++++++++ 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.PowerFx.Connectors/Execution/FormulaValueSerializer.cs b/src/libraries/Microsoft.PowerFx.Connectors/Execution/FormulaValueSerializer.cs index d1da3872f4..6298324ab6 100644 --- a/src/libraries/Microsoft.PowerFx.Connectors/Execution/FormulaValueSerializer.cs +++ b/src/libraries/Microsoft.PowerFx.Connectors/Execution/FormulaValueSerializer.cs @@ -153,13 +153,13 @@ private async Task WritePropertyAsync(string propertyName, ISwaggerSchema proper return; } - if (propertySchema == null) + if (propertySchema == null && !_schemaLessBody) { throw new PowerFxConnectorException($"Missing schema for property {propertyName}"); } // if connector has null as a type but "array" is provided, let's write it down. this is possible in case of x-ms-dynamic-properties - if (fv is TableValue tableValue && ((propertySchema.Type ?? "array") == "array")) + if (fv is TableValue tableValue && ((propertySchema?.Type ?? "array") == "array")) { StartArray(propertyName); @@ -169,12 +169,18 @@ private async Task WritePropertyAsync(string propertyName, ISwaggerSchema proper RecordValue rva = item.Value; // If we have an object schema, we will try to follow it - if (propertySchema.Items?.Type == "object" || propertySchema.Items?.Type == "array") + if (propertySchema?.Items?.Type == "object" || propertySchema?.Items?.Type == "array") { await WritePropertyAsync(null, propertySchema.Items, rva).ConfigureAwait(false); continue; } + if (propertySchema?.Items?.Type == null && _schemaLessBody) + { + await WritePropertyAsync(null, null, rva).ConfigureAwait(false); + continue; + } + // Else, we write primitive types only if (rva.Fields.Count() != 1) { @@ -193,6 +199,64 @@ private async Task WritePropertyAsync(string propertyName, ISwaggerSchema proper return; } + if (_schemaLessBody && propertySchema?.Type == null) + { + if (propertyName != null) + { + WritePropertyName(propertyName); + } + + if (fv is NumberValue numberValue) + { + WriteNumberValue(numberValue.Value); + } + else if (fv is DecimalValue decimalValue) + { + WriteDecimalValue(decimalValue.Value); + } + else if (fv is BooleanValue booleanValue) + { + WriteBooleanValue(booleanValue.Value); + } + else if (fv is StringValue stringValue) + { + WriteStringValue(stringValue.Value); + } + else if (fv is DateTimeValue dtv) + { + WriteDateTimeValue(_utcConverter.ToUTC(dtv)); + } + else if (fv is DateValue dv) + { + WriteDateValue(dv.GetConvertedValue(null)); + } + else if (fv is BlobValue bv) + { + await WriteBlobValueAsync(bv).ConfigureAwait(false); + } + else if (fv is OptionSetValue optionSetValue) + { + WriteStringValue(optionSetValue.Option); + } + else if (fv is RecordValue recordValue) + { + StartObject(); + + foreach (NamedValue field in recordValue.Fields) + { + await WritePropertyAsync(field.Name, null, field.Value).ConfigureAwait(false); + } + + EndObject(); + } + else + { + throw new NotImplementedException($"Unsupported type {fv.GetType().FullName} for schemaless body"); + } + + return; + } + switch (propertySchema.Type) { case "null": diff --git a/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/PowerPlatformConnectorTests.cs b/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/PowerPlatformConnectorTests.cs index e5edba9ce3..99ab2b6861 100644 --- a/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/PowerPlatformConnectorTests.cs +++ b/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/PowerPlatformConnectorTests.cs @@ -2182,6 +2182,88 @@ public async Task DataverseTest_WithComplexMapping() Assert.Equal(expected, actual); } + [Fact] + public async Task DataverseTest_WithItemLessArray() + { + using var testConnector = new LoggingTestServer(@"Swagger\Dataverse.json", _output); + using var httpClient = new HttpClient(testConnector); + using PowerPlatformConnectorClient client = new PowerPlatformConnectorClient("https://tip1002-002.azure-apihub.net", "b29c41cf-173b-e469-830b-4f00163d296b" /* environment Id */, "82728ddb6bfa461ea3e50e17da8ab164" /* connectionId */, () => "eyJ0eXAiOiJKV1QiLCJ...", httpClient) { SessionId = "a41bd03b-6c3c-4509-a844-e8c51b61f878" }; + + BaseRuntimeConnectorContext runtimeContext = new TestConnectorRuntimeContext("DV", client, console: _output); + ConnectorFunction[] functions = OpenApiParser.GetFunctions(new ConnectorSettings("DV") { Compatibility = ConnectorCompatibility.SwaggerCompatibility }, testConnector._apiDocument).ToArray(); + ConnectorFunction performUnboundActionWithOrganization = functions.First(f => f.Name == "PerformUnboundActionWithOrganizationV2"); + +#pragma warning disable SA1118, SA1009, SA1111, SA1137 + + // response here is fully ignored + testConnector.SetResponseFromFile(@"Responses\Dataverse_Response_3.json"); + _ = await performUnboundActionWithOrganization.InvokeAsync( + new FormulaValue[] + { + // required params + FormulaValue.New("https://aurorabapenv9984a.crm10.dynamics.com/"), + FormulaValue.New("someAction"), + + // optional param "item" with (in swagger) "item-less array" (![]) + FormulaValue.NewRecordFromFields( + new NamedValue( + "item", + new InMemoryTableValue( + IRContext.NotInSource( + RecordType.Empty() + .Add("arg1", FormulaType.String) + .Add("arg2", FormulaType.Decimal) + .Add("arg3", RecordType.Empty().Add("innerArg1", FormulaType.String)) + .Add("arg4", RecordType.Empty().Add("innerArg2", FormulaType.String).ToTable()) + .ToTable() + ), + new DValue[] + { + DValue.Of(FormulaValue.NewRecordFromFields( + new NamedValue("arg1", FormulaValue.New("foo")), + new NamedValue("arg2", FormulaValue.New(17.88m)), + new NamedValue("arg3", FormulaValue.NewRecordFromFields( + new NamedValue("innerArg1", FormulaValue.New("bar")))), + new NamedValue( + "arg4", + new InMemoryTableValue( + IRContext.NotInSource(RecordType.Empty().Add("innerArg2", FormulaType.String).ToTable()), + new DValue[] + { + DValue.Of(FormulaValue.NewRecordFromFields( + new NamedValue("innerArg2", FormulaValue.New("xyz")))) + } + )))) + } + ))) + }, + runtimeContext, + CancellationToken.None); + +#pragma warning restore SA1118, SA1009, SA1111, SA1137 + + string actual = testConnector._log.ToString(); + var version = PowerPlatformConnectorClient.Version; + + // Validate that we send the 'array' as expected + string expected = @$"POST https://tip1002-002.azure-apihub.net/invoke + authority: tip1002-002.azure-apihub.net + Authorization: Bearer eyJ0eXAiOiJKV1QiLCJ... + organization: https://aurorabapenv9984a.crm10.dynamics.com/ + path: /invoke + scheme: https + x-ms-client-environment-id: /providers/Microsoft.PowerApps/environments/b29c41cf-173b-e469-830b-4f00163d296b + x-ms-client-session-id: a41bd03b-6c3c-4509-a844-e8c51b61f878 + x-ms-request-method: POST + x-ms-request-url: /apim/commondataserviceforapps/82728ddb6bfa461ea3e50e17da8ab164/flow/api/data/v9.1.0/someAction/v2-fake + x-ms-user-agent: PowerFx/{version} + [content-header] Content-Type: application/json; charset=utf-8 + [body] [{{""arg1"":""foo"",""arg2"":17.88,""arg3"":{{""innerArg1"":""bar""}},""arg4"":[{{""innerArg2"":""xyz""}}]}}] +"; + + Assert.Equal(expected, actual); + } + // ConnectorCompatibility element will determine if an internal parameters will be suggested. [Theory] [InlineData(ConnectorCompatibility.Default, "Office365Users.SearchUserV2(", "SearchUserV2({ searchTerm:String,top:Decimal,isSearchTermRequired:Boolean,skipToken:String })")] diff --git a/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/Swagger/Dataverse.json b/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/Swagger/Dataverse.json index 393c84f985..ffa63a04dd 100644 --- a/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/Swagger/Dataverse.json +++ b/src/tests/Microsoft.PowerFx.Connectors.Tests.Shared/Swagger/Dataverse.json @@ -4683,6 +4683,78 @@ } } }, + "/{connectionId}/flow/api/data/v9.1.0/{actionName}/v2-fake": { + "post": { + "tags": [ + "PerformUnboundAction-Fake", + "Runtime" + ], + "summary": "Perform an unbound action (Preview)", + "description": "Run a global Dataverse action in a Power Platform environment, including custom actions.", + "operationId": "PerformUnboundActionWithOrganizationV2", + "consumes": [], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "connectionId", + "in": "path", + "required": true, + "type": "string", + "x-ms-visibility": "internal" + }, + { + "name": "organization", + "in": "header", + "description": "Choose an environment", + "required": true, + "x-ms-summary": "Environment", + "x-ms-visibility": "important", + "type": "string" + }, + { + "name": "actionName", + "in": "path", + "description": "Choose an action", + "required": true, + "x-ms-url-encoding": "double", + "x-ms-summary": "Action Name", + "type": "string" + }, + { + "name": "item", + "in": "body", + "description": "Action parameters", + "required": false, + "schema": { + "type": "array", + "items": { + "x-ms-internal-dev-comment": "There is no description of the fields here!" + } + }, + "x-ms-summary": "Action parameters" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "default": { + "description": "Operation Failed." + } + }, + "deprecated": false, + "x-ms-visibility": "important", + "externalDocs": { + "url": "https://docs.microsoft.com/connectors/commondataserviceforapps/#perform-an-unbound-action-(preview)" + } + } + }, "/{connectionId}/api/data/v9.1/{entityName}({recordId})/{actionName}": { "post": { "tags": [