diff --git a/README.md b/README.md index c7446052..89791053 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Server-side JavaScript that runs on the Power Pages runtime, callable from both | Sample | Integrates | Use this sample to learn | | --- | --- | --- | | [SharePoint Integration](samples/server-logic/sharepoint-integration/) | Microsoft Graph + SharePoint | Call Microsoft Graph and SharePoint Online from server logic using an Entra client-credentials app. Pairs with the [File Upload (SharePoint)](samples/spa/snippets/file-upload/sharepoint/) code-site sample. | +| [Unbound Custom API Invocation](samples/server-logic/unbound-custom-api/) | Dataverse Custom APIs | Invoke an unbound Custom Action with `POST` and a parameterized unbound Custom Function with `GET` from server logic using a ready-to-import solution. | ## Sample categories diff --git a/samples/server-logic/README.md b/samples/server-logic/README.md index 6fb4d017..7b1a667a 100644 --- a/samples/server-logic/README.md +++ b/samples/server-logic/README.md @@ -8,6 +8,7 @@ and lets a site reach services that the portal Web API can't. | Sample | Integrates | Use this sample to learn | | --- | --- | --- | | [SharePoint Integration](sharepoint-integration/) | Microsoft Graph + SharePoint | Call Microsoft Graph and SharePoint Online from server logic using an Entra client-credentials app. Pairs with the [File Upload (SharePoint)](../spa/snippets/file-upload/sharepoint/) code-site sample. | +| [Unbound Custom API Invocation](unbound-custom-api/) | Dataverse Custom APIs | Invoke an unbound Custom Action with `POST` and a parameterized unbound Custom Function with `GET` from server logic using a ready-to-import solution. | See the repository [CONTRIBUTING guide](../../CONTRIBUTING.md) for how the sample categories are organized and where a new sample belongs. diff --git a/samples/server-logic/unbound-custom-api/ServerLogicUnboundCustomApiSample_1_0_0_2.zip b/samples/server-logic/unbound-custom-api/ServerLogicUnboundCustomApiSample_1_0_0_2.zip new file mode 100644 index 00000000..d82218ff Binary files /dev/null and b/samples/server-logic/unbound-custom-api/ServerLogicUnboundCustomApiSample_1_0_0_2.zip differ diff --git a/samples/server-logic/unbound-custom-api/media/unbound-custom-action-configuration.png b/samples/server-logic/unbound-custom-api/media/unbound-custom-action-configuration.png new file mode 100644 index 00000000..fe0a8958 Binary files /dev/null and b/samples/server-logic/unbound-custom-api/media/unbound-custom-action-configuration.png differ diff --git a/samples/server-logic/unbound-custom-api/media/unbound-custom-function-configuration.png b/samples/server-logic/unbound-custom-api/media/unbound-custom-function-configuration.png new file mode 100644 index 00000000..03f11dd6 Binary files /dev/null and b/samples/server-logic/unbound-custom-api/media/unbound-custom-function-configuration.png differ diff --git a/samples/server-logic/unbound-custom-api/media/unbound-custom-function-request-parameters.png b/samples/server-logic/unbound-custom-api/media/unbound-custom-function-request-parameters.png new file mode 100644 index 00000000..d27f10da Binary files /dev/null and b/samples/server-logic/unbound-custom-api/media/unbound-custom-function-request-parameters.png differ diff --git a/samples/server-logic/unbound-custom-api/readme.md b/samples/server-logic/unbound-custom-api/readme.md new file mode 100644 index 00000000..66f32941 --- /dev/null +++ b/samples/server-logic/unbound-custom-api/readme.md @@ -0,0 +1,243 @@ +# Invoke unbound Dataverse Custom APIs from server logic + +This sample shows how Power Pages server logic can invoke both forms of an +unbound Dataverse Custom API: + +- A **Custom Action** by using `POST` with request parameters. +- A **Custom Function** by using `GET` with OData parameter aliases and no + request body. + +The importable unmanaged solution includes an enhanced-data-model Power Pages +site, its ready-to-run home page, server logic, two Custom APIs, their +parameters and response properties, and a compiled, signed Dataverse plug-in. +No code compilation, page editing, or plug-in registration is required after +import. + +![Sample home page showing successful Custom Action and parameterized Custom Function results](./screenshot.png) + +## Solution contents + +| Component | Value | +| --- | --- | +| Solution | `ServerLogicUnboundCustomApiSample_1_0_0_2.zip` | +| Site | `Custom API Support in Server Logic` | +| Site type | Enhanced-data-model site (not a code site) | +| Home page | Automatically invokes the server logic endpoint and renders both results | +| Server logic endpoint | `sl-unbound-customapi-manual-test` | +| Custom Action | `new_ServerLogicUnboundActionManualTest` | +| Custom Function | `new_ServerLogicUnboundFunctionManualTest` | +| Action inputs | `InputText` (String), `InputNumber` (Integer) | +| Function inputs | `InputText` (String), `InputNumber` (Integer) | +| Action and function outputs | `ResponseText` (String), `ResponseNumber` (Integer) | +| Access | Anonymous Users and Authenticated Users web roles | + +## Prerequisites + +- A Power Platform environment with Power Pages provisioned. +- Permission to import solutions and reactivate a Power Pages site. +- Power Pages runtime version `9.8.9.xx` or later for unbound Custom Function + calls through `InvokeCustomApi`. +- The standard Power Pages managed solutions. The package declares these + dependencies: + - `PowerPages_CoreBase` version `1.1.2605.1` or later. + - `Basic` version `1.0`. + +## Import and activate the sample + +1. Download + [`ServerLogicUnboundCustomApiSample_1_0_0_2.zip`](./ServerLogicUnboundCustomApiSample_1_0_0_2.zip). +2. In [Power Apps](https://make.powerapps.com), open the target environment, + select **Solutions**, and import the ZIP file. +3. Open [Power Pages](https://make.powerpages.microsoft.com) in the same + environment. Locate the imported **Custom API Support in Server Logic** + site and reactivate it. +4. Choose an available site address when prompted. +5. Publish all customizations and wait for the site to finish provisioning. + +Site reactivation is required after the first import because a Power Pages site +address cannot be provisioned by a portable solution package. The package +intentionally omits a primary domain so importing a newer sample version does +not replace the address of an already activated site. + +## Run the sample + +Browse to the activated site's home page: + +```text +https:/// +``` + +The page automatically calls: + +```text +GET /_api/serverlogics/sl-unbound-customapi-manual-test +``` + +It displays the action and function request values, HTTP statuses, response +values, pass/fail badges, and the reference code used by the sample. You can +also browse directly to the endpoint URL to inspect its raw JSON response. + +The endpoint returns a wrapper whose `data` property is a JSON string. A +successful response has this shape: + +```json +{ + "success": true, + "serverLogicName": "sl-unbound-customapi-manual-test", + "data": "{\"overallPass\":true,...}" +} +``` + +After parsing `data`, both calls report the expected values: + +```json +{ + "overallPass": true, + "action": { + "method": "POST", + "response": { + "statusCode": 200, + "body": { + "ResponseNumber": 42 + } + }, + "passed": true + }, + "function": { + "method": "GET", + "request": { + "InputText": "Manual GET parameter test / aliases", + "InputNumber": 61 + }, + "response": { + "statusCode": 200, + "body": { + "ResponseNumber": 68 + } + }, + "passed": true + } +} +``` + +## How it works + +The dedicated home Web Template renders the page content and embeds the +client-side script, so the sample does not depend on JavaScript hooks from a +starter site's Header or Footer. The script uses a normal same-origin request +and parses the Server Logic response: + +```javascript +const response = await fetch( + "/_api/serverlogics/sl-unbound-customapi-manual-test", + { + method: "GET", + credentials: "same-origin", + headers: { Accept: "application/json" } + } +); +const outer = await response.json(); +const result = JSON.parse(outer.data); +``` + +The server logic sends a body with the unbound action: + +```javascript +var actionRequest = { + InputText: "Manual test from Power Pages", + InputNumber: 35 +}; + +var actionResponse = Server.Connector.Dataverse.InvokeCustomApi( + "POST", + "new_ServerLogicUnboundActionManualTest", + JSON.stringify(actionRequest) +); +``` + +The unbound function uses `GET` and puts its input values in OData parameter +aliases. The binding list stays in the operation path, while the encoded values +stay in the query string: + +```javascript +function encodeODataStringAlias(value) { + return encodeURIComponent("'" + value.replace(/'/g, "''") + "'"); +} + +var functionRequest = { + InputText: "Manual GET parameter test / aliases", + InputNumber: 61 +}; +var functionUrl = + "new_ServerLogicUnboundFunctionManualTest(InputText=@text,InputNumber=@number)" + + "?@text=" + encodeODataStringAlias(functionRequest.InputText) + + "&@number=" + functionRequest.InputNumber; + +var functionResponse = Server.Connector.Dataverse.InvokeCustomApi( + "GET", + functionUrl +); +``` + +The plug-in echoes each input text and adds `7` to `InputNumber`. The action +therefore returns `42`, while the function returns `68`. The server logic parses +the connector response and sets `overallPass` only when both calls return the +expected values. + +## Imported Custom API configuration + +The action is global (`Binding Type = Global`) and is not a function: + +![Unbound Custom Action configuration](./media/unbound-custom-action-configuration.png) + +The function is also global and has `Is Function = Yes`: + +![Unbound Custom Function configuration](./media/unbound-custom-function-configuration.png) + +Its two inputs are configured as Custom API request parameters: + +![Unbound Custom Function request parameters](./media/unbound-custom-function-request-parameters.png) + +## Source files + +- [`source/homepage-content.html`](./source/homepage-content.html) contains the + page markup and styles included in the solution. +- [`source/homepage.js`](./source/homepage.js) invokes the Server Logic endpoint + and renders the action and function results. The dedicated home Web Template + in the solution embeds this script after the editable page content. +- [`source/header.html`](./source/header.html) and + [`source/footer.html`](./source/footer.html) keep the imported site + self-contained and free of starter-template snippet dependencies. +- [`source/sl-unbound-customapi-manual-test.sl`](./source/sl-unbound-customapi-manual-test.sl) + contains the server logic included in the solution. +- [`source/EchoPlugin.cs`](./source/EchoPlugin.cs) contains the equivalent + Dataverse plug-in source. The solution already contains the compiled, signed + assembly; the source is included for learning and review. + +## Security note + +This demonstration endpoint is assigned to both anonymous and authenticated +web roles because it returns only deterministic sample values. For production +code, grant only the minimum required web roles, validate every input, avoid +returning sensitive Dataverse data, and review the privileges granted to the +portal application user. + +## Troubleshooting + +- **404 from the server logic URL:** confirm that the imported site is active, + the endpoint name is unchanged, and customizations are published. +- **The home page stays on "Running":** publish all customizations and confirm + that the imported Home Web Template is still assigned to the Home Page + Template. +- **The browser sends two requests to the server logic endpoint:** import + version `1.0.0.2` or later, publish all customizations, and clear the site's + configuration cache. This version removes legacy page JavaScript and also + prevents duplicate initialization. +- **Custom API not found:** confirm the two Custom APIs and the plug-in assembly + were imported successfully, then publish all customizations. +- **The action succeeds but the function reports HTTP 400:** the target + environment has not yet received Power Pages runtime version `9.8.9.xx` or + later. Parameterized unbound Custom Functions through `InvokeCustomApi` are + available starting with `9.8.9.xx`. +- **Managed dependency error during import:** provision Power Pages in the + target environment so the required Power Pages core solutions are installed. diff --git a/samples/server-logic/unbound-custom-api/screenshot.png b/samples/server-logic/unbound-custom-api/screenshot.png new file mode 100644 index 00000000..c5b97a1b Binary files /dev/null and b/samples/server-logic/unbound-custom-api/screenshot.png differ diff --git a/samples/server-logic/unbound-custom-api/source/EchoPlugin.cs b/samples/server-logic/unbound-custom-api/source/EchoPlugin.cs new file mode 100644 index 00000000..9a13819d --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/EchoPlugin.cs @@ -0,0 +1,45 @@ +using System; +using System.Reflection; +using Microsoft.Xrm.Sdk; + +[assembly: AssemblyTitle("PowerPages.ServerLogic.UnboundCustomApiManualTest")] +[assembly: AssemblyDescription("Dataverse plug-in for the Power Pages Server Logic Custom API sample.")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + +namespace PowerPages.ServerLogic.UnboundCustomApiManualTest +{ + /// + /// Returns values that show whether the Custom API received the request. + /// + public sealed class EchoPlugin : IPlugin + { + /// + /// Executes the sample Custom API. + /// + /// Provides Dataverse execution services. + public void Execute(IServiceProvider serviceProvider) + { + var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext)); + var tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService)); + var inputText = context.InputParameters.Contains("InputText") + ? context.InputParameters["InputText"] as string + : string.Empty; + var inputNumber = context.InputParameters.Contains("InputNumber") + && context.InputParameters["InputNumber"] is int + ? (int)context.InputParameters["InputNumber"] + : 0; + + context.OutputParameters["ResponseText"] = string.IsNullOrEmpty(inputText) + ? string.Format("{0} completed successfully.", context.MessageName) + : string.Format("{0} received: {1}", context.MessageName, inputText); + context.OutputParameters["ResponseNumber"] = inputNumber + 7; + + tracingService.Trace( + "Sample Custom API completed. Message={0}, InputNumber={1}, ResponseNumber={2}.", + context.MessageName, + inputNumber, + inputNumber + 7); + } + } +} diff --git a/samples/server-logic/unbound-custom-api/source/footer.html b/samples/server-logic/unbound-custom-api/source/footer.html new file mode 100644 index 00000000..86ac4101 --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/footer.html @@ -0,0 +1,5 @@ + diff --git a/samples/server-logic/unbound-custom-api/source/header.html b/samples/server-logic/unbound-custom-api/source/header.html new file mode 100644 index 00000000..7391082c --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/header.html @@ -0,0 +1,8 @@ +
+
+ + Custom API Support in Server Logic + + Power Pages Server Logic sample +
+
diff --git a/samples/server-logic/unbound-custom-api/source/homepage-content.html b/samples/server-logic/unbound-custom-api/source/homepage-content.html new file mode 100644 index 00000000..eb49c095 --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/homepage-content.html @@ -0,0 +1,297 @@ + + +
+

Custom API Support in Server Logic

+

+ This Power Pages page automatically invokes one unbound Custom Action and one + parameterized unbound Custom Function through the Server Logic Dataverse connector. +

+

+ Endpoint: + GET /_api/serverlogics/sl-unbound-customapi-manual-test +

+ +
+ + Running the Server Logic sample... +
+ +
+
+ Running +

Unbound Custom Action

+
+
Method
+
POST
+
API
+
new_ServerLogicUnboundActionManualTest
+
Request text
+
-
+
Request number
+
-
+
HTTP status
+
-
+
Response text
+
-
+
Response number
+
-
+
+
+ Request and response JSON +
Waiting for the endpoint response.
+
+
+ +
+ Running +

Parameterized Custom Function

+
+
Method
+
GET
+
API
+
new_ServerLogicUnboundFunctionManualTest
+
Function URL
+
Waiting for the endpoint response.
+
Aliases
+
@text and @number
+
Request text
+
-
+
Request number
+
-
+
HTTP status
+
-
+
Response text
+
-
+
Response number
+
-
+
+
+ Request and response JSON +
Waiting for the endpoint response.
+
+
+
+ +
+

Security note

+

+ This demonstration endpoint is assigned to both anonymous and authenticated web roles + because it returns only deterministic sample values. For production code, grant only the + minimum required web roles, validate every input, avoid returning sensitive Dataverse data, + and review the privileges granted to the portal application user. +

+
+ +
+

Reference code

+

+ The page uses a normal client-side fetch. The endpoint runs the Server Logic + source on the server, where the Dataverse connector invokes the same Custom API contracts. +

+

Homepage request

+
const response = await fetch(
+  "/_api/serverlogics/sl-unbound-customapi-manual-test",
+  { method: "GET", headers: { Accept: "application/json" } }
+);
+const outer = await response.json();
+const result = JSON.parse(outer.data);
+

Server Logic Custom Action

+
Server.Connector.Dataverse.InvokeCustomApi(
+  "POST",
+  "new_ServerLogicUnboundActionManualTest",
+  JSON.stringify(actionRequest)
+);
+

Server Logic parameterized Custom Function

+
Server.Connector.Dataverse.InvokeCustomApi(
+  "GET",
+  "new_ServerLogicUnboundFunctionManualTest(InputText=@text,InputNumber=@number)"
+    + "?@text=" + encodedText
+    + "&@number=" + functionRequest.InputNumber
+);
+
+
diff --git a/samples/server-logic/unbound-custom-api/source/homepage.js b/samples/server-logic/unbound-custom-api/source/homepage.js new file mode 100644 index 00000000..fcaaf80c --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/homepage.js @@ -0,0 +1,114 @@ +(() => { + "use strict"; + + const sampleRoot = document.querySelector(".custom-api-sample"); + if (!sampleRoot || sampleRoot.dataset.serverLogicSampleInitialized === "true") { + return; + } + + sampleRoot.dataset.serverLogicSampleInitialized = "true"; + + const endpoint = "/_api/serverlogics/sl-unbound-customapi-manual-test"; + + const setText = (id, value) => { + const element = document.getElementById(id); + if (element) { + element.textContent = value === null || value === undefined ? "-" : String(value); + } + }; + + const setBadge = (id, passed) => { + const element = document.getElementById(id); + if (!element) { + return; + } + + element.textContent = passed ? "PASS" : "FAIL"; + element.classList.remove("sample-pass", "sample-fail"); + element.classList.add(passed ? "sample-pass" : "sample-fail"); + }; + + const renderOperation = (prefix, operation) => { + const response = operation.response || {}; + const body = response.body || {}; + const request = operation.request || {}; + + setBadge(`${prefix}-badge`, Boolean(operation.passed)); + setText(`${prefix}-method`, operation.method); + setText(`${prefix}-name`, operation.name); + setText(`${prefix}-request-text`, request.InputText); + setText(`${prefix}-request-number`, request.InputNumber); + setText(`${prefix}-status`, `${response.statusCode} ${response.reasonPhrase || ""}`.trim()); + setText(`${prefix}-response-text`, body.ResponseText); + setText(`${prefix}-response-number`, body.ResponseNumber); + setText( + `${prefix}-json`, + JSON.stringify({ request, response }, null, 2) + ); + }; + + const renderSuccess = (outer, result) => { + const overall = document.getElementById("sample-overall"); + const passed = Boolean(outer.success && result.overallPass); + + setText( + "sample-overall-text", + passed + ? `PASS - Action and parameterized Function succeeded (request ${outer.requestId}).` + : "FAIL - One or more Custom API checks did not return the expected values." + ); + + if (overall) { + overall.classList.add(passed ? "sample-pass" : "sample-fail"); + } + + renderOperation("sample-action", result.action || {}); + renderOperation("sample-function", result.function || {}); + setText("sample-function-url", result.function && result.function.url); + }; + + const renderFailure = (error) => { + const overall = document.getElementById("sample-overall"); + + setText("sample-overall-text", `FAIL - ${error.message}`); + if (overall) { + overall.classList.add("sample-fail"); + } + + setBadge("sample-action-badge", false); + setBadge("sample-function-badge", false); + }; + + const runSample = async () => { + try { + const response = await fetch(endpoint, { + method: "GET", + credentials: "same-origin", + headers: { + Accept: "application/json" + } + }); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`Server Logic returned HTTP ${response.status}: ${text}`); + } + + const outer = JSON.parse(text); + if (!outer.success) { + throw new Error(outer.error || "The Server Logic response reported failure."); + } + + const result = typeof outer.data === "string" ? JSON.parse(outer.data) : outer.data; + renderSuccess(outer, result); + } catch (error) { + renderFailure(error instanceof Error ? error : new Error(String(error))); + } + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", runSample, { once: true }); + } else { + runSample(); + } +})(); diff --git a/samples/server-logic/unbound-custom-api/source/sl-unbound-customapi-manual-test.sl b/samples/server-logic/unbound-custom-api/source/sl-unbound-customapi-manual-test.sl new file mode 100644 index 00000000..05a5e433 --- /dev/null +++ b/samples/server-logic/unbound-custom-api/source/sl-unbound-customapi-manual-test.sl @@ -0,0 +1,69 @@ +function parseConnectorResponse(raw) { + var response = JSON.parse(raw); + var body = response.Body ? JSON.parse(response.Body) : null; + + return { + statusCode: response.StatusCode, + isSuccess: response.IsSuccessStatusCode, + reasonPhrase: response.ReasonPhrase, + body: body + }; +} + +function encodeODataStringAlias(value) { + return encodeURIComponent("'" + value.replace(/'/g, "''") + "'"); +} + +function get() { + var actionRequest = { + InputText: "Manual test from Power Pages", + InputNumber: 35 + }; + var functionRequest = { + InputText: "Manual GET parameter test / aliases", + InputNumber: 61 + }; + var functionUrl = "new_ServerLogicUnboundFunctionManualTest(InputText=@text,InputNumber=@number)" + + "?@text=" + encodeODataStringAlias(functionRequest.InputText) + + "&@number=" + functionRequest.InputNumber; + var actionResponse = parseConnectorResponse( + Server.Connector.Dataverse.InvokeCustomApi( + "POST", + "new_ServerLogicUnboundActionManualTest", + JSON.stringify(actionRequest) + ) + ); + var functionResponse = parseConnectorResponse( + Server.Connector.Dataverse.InvokeCustomApi( + "GET", + functionUrl + ) + ); + var actionPassed = actionResponse.isSuccess + && actionResponse.body + && actionResponse.body.ResponseNumber === 42 + && actionResponse.body.ResponseText.indexOf(actionRequest.InputText) >= 0; + var functionPassed = functionResponse.isSuccess + && functionResponse.body + && functionResponse.body.ResponseNumber === 68 + && functionResponse.body.ResponseText.indexOf(functionRequest.InputText) >= 0; + + return JSON.stringify({ + overallPass: actionPassed && functionPassed, + action: { + method: "POST", + name: "new_ServerLogicUnboundActionManualTest", + request: actionRequest, + response: actionResponse, + passed: actionPassed + }, + function: { + method: "GET", + name: "new_ServerLogicUnboundFunctionManualTest", + url: functionUrl, + request: functionRequest, + response: functionResponse, + passed: functionPassed + } + }); +}