diff --git a/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace b/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace new file mode 100644 index 00000000..538ad65a --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace @@ -0,0 +1,21 @@ +{ + "folders": [ + { + "name": "SimpleJson", + "path": "./application/simple_json" + }, + { + "name": "Connector", + "path": "./application/directions_connector" + }, + { + "name": "Server", + "path": "./server" + }, + { + "name": "Workshop", + "path": "workshop" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al new file mode 100644 index 00000000..f4f2a1d4 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al @@ -0,0 +1,128 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +using System.Utilities; + +/// +/// Helper codeunit for authentication and registration with the Connector API. +/// Pre-written to save time during the workshop. +/// +codeunit 50121 "Connector Auth" +{ + Access = Internal; + + /// + /// Registers a new user with the Connector API and stores the API key. + /// + procedure RegisterUser(var ConnectorSetup: Record "Connector Connection Setup") + var + HttpClient: HttpClient; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + HttpContent: HttpContent; + HttpHeaders: HttpHeaders; + JsonObject: JsonObject; + JsonToken: JsonToken; + ResponseText: Text; + RequestBody: Text; + begin + if ConnectorSetup."API Base URL" = '' then + Error('Please specify the API Base URL before registering.'); + + if ConnectorSetup."User Name" = '' then + Error('Please specify a User Name before registering.'); + + // Create request body + JsonObject.Add('name', ConnectorSetup."User Name"); + JsonObject.WriteTo(RequestBody); + + // Prepare HTTP request + HttpRequest.Content.WriteFrom(RequestBody); + HttpRequest.Content.GetHeaders(HttpHeaders); + if HttpHeaders.Contains('Content-Type') then + HttpHeaders.Remove('Content-Type'); + HttpHeaders.Add('Content-Type', 'application/json'); + + HttpRequest.Method := 'POST'; + HttpRequest.SetRequestUri(ConnectorSetup."API Base URL" + 'register'); + + // Send request + if not HttpClient.Send(HttpRequest, HttpResponse) then + Error('Failed to connect to the API server.'); + + // Parse response + HttpResponse.Content.ReadAs(ResponseText); + + if not HttpResponse.IsSuccessStatusCode() then + Error('Registration failed: %1', ResponseText); + + // Extract API key from response + if JsonObject.ReadFrom(ResponseText) then begin + if JsonObject.Get('key', JsonToken) then begin + ConnectorSetup.SetAPIKey(JsonToken.AsValue().AsText()); + ConnectorSetup.Registered := true; + ConnectorSetup.Modify(); + end else + Error('API key not found in response.'); + end else + Error('Invalid response format from API.'); + end; + + /// + /// Tests the connection to the API by calling the /peek endpoint. + /// + procedure TestConnection(ConnectorSetup: Record "Connector Connection Setup") + var + HttpClient: HttpClient; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + begin + if ConnectorSetup."API Base URL" = '' then + Error('Please specify the API Base URL.'); + + if not ConnectorSetup.Registered then + Error('Please register first to get an API key.'); + + // Prepare HTTP request + HttpRequest.Method := 'GET'; + HttpRequest.SetRequestUri(ConnectorSetup."API Base URL" + 'peek'); + AddAuthHeader(HttpRequest, ConnectorSetup); + + // Send request + if not HttpClient.Send(HttpRequest, HttpResponse) then + Error('Failed to connect to the API server.'); + + if not HttpResponse.IsSuccessStatusCode() then + Error('Connection test failed with status: %1', HttpResponse.HttpStatusCode()); + end; + + /// + /// Adds the authentication header to an HTTP request. + /// + procedure AddAuthHeader(var HttpRequest: HttpRequestMessage; ConnectorSetup: Record "Connector Connection Setup") + var + HttpHeaders: HttpHeaders; + begin + // Prepare HTTP request + HttpRequest.GetHeaders(HttpHeaders); + HttpHeaders.Add('X-Service-Key', ConnectorSetup.GetAPIKeyText()); + end; + + /// + /// Gets the connection setup record, ensuring it exists. + /// + procedure GetConnectionSetup(var ConnectorSetup: Record "Connector Connection Setup") + begin + if not ConnectorSetup.Get() then + Error('Connector is not configured. Please open the Connector Connection Setup page.'); + + if ConnectorSetup."API Base URL" = '' then + Error('API Base URL is not configured.'); + + if not ConnectorSetup.Registered then + Error('Not registered with the API. Please register first.'); + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al new file mode 100644 index 00000000..aa002144 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al @@ -0,0 +1,136 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +/// +/// Setup page for Connector API connection. +/// Allows users to configure the API URL and register to get an API key. +/// +page 50122 "Connector Connection Setup" +{ + Caption = 'Connector Setup'; + PageType = Card; + SourceTable = "Connector Connection Setup"; + InsertAllowed = false; + DeleteAllowed = false; + ApplicationArea = All; + UsageCategory = Administration; + + layout + { + area(content) + { + group(General) + { + Caption = 'Connection Settings'; + + field("API Base URL"; Rec."API Base URL") + { + ApplicationArea = All; + ToolTip = 'Specifies the base URL for the Connector API server (e.g., https://workshop-server.azurewebsites.net/)'; + ShowMandatory = true; + } + field("User Name"; Rec."User Name") + { + ApplicationArea = All; + ToolTip = 'Specifies the user name to register with the API'; + ShowMandatory = true; + } + field(Registered; Rec.Registered) + { + ApplicationArea = All; + ToolTip = 'Indicates whether you have successfully registered with the API'; + Style = Favorable; + StyleExpr = Rec.Registered; + } + field("API Key"; APIKeyText) + { + ApplicationArea = All; + Caption = 'API Key'; + ToolTip = 'Specifies the API key received after registration'; + + trigger OnAssistEdit() + var + NewAPIKey: Text; + begin + NewAPIKey := APIKeyText; + if NewAPIKey <> '' then begin + Rec.SetAPIKey(NewAPIKey); + Rec.Modify(); + CurrPage.Update(); + end; + end; + } + } + } + } + + actions + { + area(Processing) + { + action(Register) + { + ApplicationArea = All; + Caption = 'Register'; + ToolTip = 'Register with the Connector API to get an API key'; + Image = Approve; + Promoted = true; + PromotedCategory = Process; + PromotedOnly = true; + + trigger OnAction() + var + ConnectorAuth: Codeunit "Connector Auth"; + begin + ConnectorAuth.RegisterUser(Rec); + CurrPage.Update(); + UpdateAPIKeyText(); + Message('Registration successful! Your API key has been saved.'); + end; + } + action(TestConnection) + { + ApplicationArea = All; + Caption = 'Test Connection'; + ToolTip = 'Test the connection to the Connector API'; + Image = ValidateEmailLoggingSetup; + Promoted = true; + PromotedCategory = Process; + PromotedOnly = true; + + trigger OnAction() + var + ConnectorAuth: Codeunit "Connector Auth"; + begin + ConnectorAuth.TestConnection(Rec); + Message('Connection test successful!'); + end; + } + } + } + + trigger OnOpenPage() + begin + Rec.GetOrCreate(); + UpdateAPIKeyText(); + end; + + trigger OnAfterGetCurrRecord() + begin + UpdateAPIKeyText(); + end; + + local procedure UpdateAPIKeyText() + begin + if not IsNullGuid(Rec."API Key") then + APIKeyText := Rec.GetAPIKeyText() + else + APIKeyText := ''; + end; + + var + APIKeyText: Text; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al new file mode 100644 index 00000000..36953bb7 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +/// +/// Stores connection settings for Connector API. +/// This is a singleton table that holds the API URL and authentication key. +/// +table 50122 "Connector Connection Setup" +{ + Caption = 'Connector Connection Setup'; + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) + { + Caption = 'Primary Key'; + DataClassification = SystemMetadata; + } + field(10; "API Base URL"; Text[250]) + { + Caption = 'API Base URL'; + DataClassification = CustomerContent; + + trigger OnValidate() + begin + if "API Base URL" <> '' then + if not "API Base URL".EndsWith('/') then + "API Base URL" := "API Base URL" + '/'; + end; + } + field(11; "API Key"; Guid) + { + Caption = 'API Key'; + DataClassification = EndUserIdentifiableInformation; + } + field(20; "User Name"; Text[100]) + { + Caption = 'User Name'; + DataClassification = EndUserIdentifiableInformation; + } + field(21; Registered; Boolean) + { + Caption = 'Registered'; + DataClassification = CustomerContent; + Editable = false; + } + } + + keys + { + key(PK; "Primary Key") + { + Clustered = true; + } + } + + /// + /// Gets or creates the singleton setup record. + /// + procedure GetOrCreate(): Boolean + begin + if not Get() then begin + Init(); + "Primary Key" := ''; + Insert(); + end; + exit(true); + end; + + /// + /// Sets the API key from text value. + /// + procedure SetAPIKey(NewAPIKey: Text) + var + APIKeyGuid: Guid; + begin + if Evaluate(APIKeyGuid, NewAPIKey) then + "API Key" := APIKeyGuid + else + Error('Invalid API Key format. Must be a valid GUID.'); + end; + + /// + /// Gets the API key as text. + /// + procedure GetAPIKeyText(): Text + begin + exit(Format("API Key").Replace('{', '').Replace('}', '').ToLower()); + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al new file mode 100644 index 00000000..1fb5c48f --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al @@ -0,0 +1,206 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration.Send; +using Microsoft.eServices.EDocument.Integration.Receive; +using Microsoft.eServices.EDocument.Integration.Interfaces; +using System.Utilities; + +/// +/// Implements the IDocumentSender and IDocumentReceiver interfaces for Connector. +/// This codeunit handles sending and receiving E-Documents via the Connector API. +/// +codeunit 50123 "Connector Integration" implements IDocumentSender, IDocumentReceiver +{ + Access = Internal; + + // ============================================================================ + // SENDING DOCUMENTS + // ============================================================================ + + // ============================================================================ + // TODO: Exercise 2 (10 minutes) + // Now send the SimpleJSON to the endpoint, using the Connector API. + // + // TASK: Do the TODOs in the Send procedure below + // ============================================================================ + procedure Send(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; SendContext: Codeunit SendContext) + var + ConnectorSetup: Record "Connector Connection Setup"; + ConnectorAuth: Codeunit "Connector Auth"; + ConnectorRequests: Codeunit "Connector Requests"; + HttpClient: HttpClient; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + TempBlob: Codeunit "Temp Blob"; + JsonContent, APIEndpoint : Text; + begin + ConnectorAuth.GetConnectionSetup(ConnectorSetup); + + // TODO: Get temp blob with json from SendContext + // - Tips: Use SendContext.GetTempBlob() + // - Tips: Use ConnectorRequests.ReadJsonFromBlob(TempBlob) to read json text from blob + + + TempBlob := SendContext.GetTempBlob(); + JsonContent := ConnectorRequests.ReadJsonFromBlob(TempBlob); + // + + // TODO: Create POST request to 'enqueue' endpoint + // - Tips: Add enqueue to the base URL from ConnectorSetup + + // + APIEndpoint := ConnectorSetup."API Base URL" + 'enqueue'; + + ConnectorRequests.CreatePostRequest(APIEndpoint, JsonContent, HttpRequest); + ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup); + + // TODO: Send the HTTP request and handle the response using HttpClient + + if not HttpClient.Send(HttpRequest, HttpResponse) then + Error('Failed to connect to the API server.'); + // + + SendContext.Http().SetHttpRequestMessage(HttpRequest); + SendContext.Http().SetHttpResponseMessage(HttpResponse); + ConnectorRequests.CheckResponseSuccess(HttpResponse); + end; + + // ============================================================================ + // RECEIVING DOCUMENTS + // ============================================================================ + + // ============================================================================ + // TODO: Exercise 3,A (10 minutes) + // Receive a list of documents from the Connector API. + // + // TASK: Do the todos + // ============================================================================ + procedure ReceiveDocuments(var EDocumentService: Record "E-Document Service"; DocumentsMetadata: Codeunit "Temp Blob List"; ReceiveContext: Codeunit ReceiveContext) + var + ConnectorSetup: Record "Connector Connection Setup"; + ConnectorAuth: Codeunit "Connector Auth"; + ConnectorRequests: Codeunit "Connector Requests"; + HttpClient: HttpClient; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + JsonObject: JsonObject; + JsonToken: JsonToken; + JsonArray: JsonArray; + TempBlob: Codeunit "Temp Blob"; + ResponseText: Text; + DocumentJson: Text; + APIEndpoint: Text; + begin + ConnectorAuth.GetConnectionSetup(ConnectorSetup); + + + // TODO: Create Get request to 'peek' endpoint + // - Tips: Add peek to the base URL from ConnectorSetup + APIEndpoint := ConnectorSetup."API Base URL" + 'peek'; + + // + + ConnectorRequests.CreateGetRequest(APIEndpoint, HttpRequest); + ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup); + + + // TODO: Send the HTTP request and handle the response using HttpClient + + if not HttpClient.Send(HttpRequest, HttpResponse) then + Error('Failed to connect to the API server.'); + // + + ReceiveContext.Http().SetHttpRequestMessage(HttpRequest); + ReceiveContext.Http().SetHttpResponseMessage(HttpResponse); + ConnectorRequests.CheckResponseSuccess(HttpResponse); + + ResponseText := ConnectorRequests.GetResponseText(HttpResponse); + + if JsonObject.ReadFrom(ResponseText) then begin + if JsonObject.Get('items', JsonToken) then begin + JsonArray := JsonToken.AsArray(); + foreach JsonToken in JsonArray do begin + Clear(TempBlob); + JsonToken.WriteTo(DocumentJson); + ConnectorRequests.WriteTextToBlob(DocumentJson, TempBlob); + + // TODO: Add TempBlob to DocumentsMetadata so we can process it later in DownloadDocument + DocumentsMetadata.Add(TempBlob); + // + end; + end; + end; + end; + + // ============================================================================ + // TODO: Exercise 3.B (5 minutes) + // Download a single document from the Connector API (dequeue). + // + // TASK: Do the todos + // ============================================================================ + procedure DownloadDocument(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; DocumentMetadata: codeunit "Temp Blob"; ReceiveContext: Codeunit ReceiveContext) + var + ConnectorSetup: Record "Connector Connection Setup"; + ConnectorAuth: Codeunit "Connector Auth"; + ConnectorRequests: Codeunit "Connector Requests"; + HttpClient: HttpClient; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + JsonObject: JsonObject; + JsonToken: JsonToken; + TempBlob: Codeunit "Temp Blob"; + ResponseText: Text; + DocumentJson: Text; + APIEndpoint: Text; + begin + ConnectorAuth.GetConnectionSetup(ConnectorSetup); + + // TODO: Create Get request to 'dequeue' endpoint + // - Tips: Add dequeue to the base URL from ConnectorSetup + + APIEndpoint := ConnectorSetup."API Base URL" + 'dequeue'; + // + + ConnectorRequests.CreateGetRequest(APIEndpoint, HttpRequest); + ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup); + + // TODO: Send the HTTP request and handle the response using HttpClient + // + if not HttpClient.Send(HttpRequest, HttpResponse) then + Error('Failed to connect to the API server.'); + + ReceiveContext.Http().SetHttpRequestMessage(HttpRequest); + ReceiveContext.Http().SetHttpResponseMessage(HttpResponse); + ConnectorRequests.CheckResponseSuccess(HttpResponse); + + ResponseText := ConnectorRequests.GetResponseText(HttpResponse); + if JsonObject.ReadFrom(ResponseText) then begin + if JsonObject.Get('document', JsonToken) then begin + JsonToken.WriteTo(DocumentJson); + TempBlob := ReceiveContext.GetTempBlob(); + ConnectorRequests.WriteTextToBlob(DocumentJson, TempBlob); + end else + Error('No document found in response.'); + end; + end; + + // ============================================================================ + // Event Subscriber - Opens setup page when configuring the service + // ============================================================================ + [EventSubscriber(ObjectType::Page, Page::"E-Document Service", OnBeforeOpenServiceIntegrationSetupPage, '', false, false)] + local procedure OnBeforeOpenServiceIntegrationSetupPage(EDocumentService: Record "E-Document Service"; var IsServiceIntegrationSetupRun: Boolean) + var + ConnectorSetup: Page "Connector Connection Setup"; + begin + if EDocumentService."Service Integration V2" <> EDocumentService."Service Integration V2"::Connector then + exit; + + ConnectorSetup.RunModal(); + IsServiceIntegrationSetupRun := true; + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al new file mode 100644 index 00000000..5d91f226 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +using Microsoft.eServices.EDocument.Integration.Interfaces; + +/// +/// Extends the Service Integration V2 enum to add Connector integration. +/// This enum value is used to identify which integration implementation to use. +/// +enumextension 50124 "Connector Integration" extends "Service Integration" +{ + value(50124; "Connector") + { + Caption = 'Connector'; + Implementation = IDocumentSender = "Connector Integration", + IDocumentReceiver = "Connector Integration"; + } + +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al new file mode 100644 index 00000000..d11adc14 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Integration; + +using System.Utilities; + +/// +/// Helper codeunit for building HTTP requests for the Connector API. +/// Pre-written to save time during the workshop. +/// +codeunit 50125 "Connector Requests" +{ + Access = Internal; + + /// + /// Creates an HTTP POST request with JSON content. + /// + procedure CreatePostRequest(Url: Text; JsonContent: Text; var HttpRequest: HttpRequestMessage) + var + HttpHeaders: HttpHeaders; + HttpContent: HttpContent; + begin + HttpRequest.Content.WriteFrom(JsonContent); + HttpRequest.Content.GetHeaders(HttpHeaders); + // Prepare HTTP request + if HttpHeaders.Contains('Content-Type') then + HttpHeaders.Remove('Content-Type'); + HttpHeaders.Add('Content-Type', 'application/json'); + + HttpRequest.Method := 'POST'; + HttpRequest.SetRequestUri(Url); + end; + + /// + /// Creates an HTTP GET request. + /// + procedure CreateGetRequest(Url: Text; var HttpRequest: HttpRequestMessage) + begin + HttpRequest.Method := 'GET'; + HttpRequest.SetRequestUri(Url); + end; + + /// + /// Reads the response content as text. + /// + procedure GetResponseText(HttpResponse: HttpResponseMessage): Text + var + ResponseText: Text; + begin + HttpResponse.Content.ReadAs(ResponseText); + exit(ResponseText); + end; + + /// + /// Checks if the response is successful and throws an error if not. + /// + procedure CheckResponseSuccess(HttpResponse: HttpResponseMessage) + var + ResponseText: Text; + begin + if not HttpResponse.IsSuccessStatusCode() then begin + ResponseText := GetResponseText(HttpResponse); + Error('API request failed with status %1: %2', HttpResponse.HttpStatusCode(), ResponseText); + end; + end; + + /// + /// Reads JSON content from a TempBlob. + /// + procedure ReadJsonFromBlob(var TempBlob: Codeunit "Temp Blob"): Text + var + InStr: InStream; + JsonText: Text; + begin + TempBlob.CreateInStream(InStr, TextEncoding::UTF8); + InStr.ReadText(JsonText); + exit(JsonText); + end; + + /// + /// Writes text content to a TempBlob. + /// + procedure WriteTextToBlob(TextContent: Text; var TempBlob: Codeunit "Temp Blob") + var + OutStr: OutStream; + begin + TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8); + OutStr.WriteText(TextContent); + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al new file mode 100644 index 00000000..21d6c39b --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al @@ -0,0 +1,239 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Format; + +using System.TestLibraries.Utilities; +using Microsoft.eServices.EDocument; +using Microsoft.EServices.EDocument.Integration; +using Microsoft.eServices.EDocument.Integration.Send; +using Microsoft.eServices.EDocument.Integration.Receive; +using System.Utilities; + +/// +/// Test suite for Connector Integration exercises. +/// Tests verify that the three main exercises are implemented correctly: +/// - Exercise 2.A: Send (enqueue documents) +/// - Exercise 2.B: ReceiveDocuments (peek at documents) +/// - Exercise 2.C: DownloadDocument (dequeue documents) +/// +codeunit 50129 "Connector Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibraryLowerPermission: Codeunit "Library - Lower Permissions"; + TestCustomerNo: Code[20]; + TestVendorNo: Code[20]; + + local procedure Initialize() + begin + LibraryLowerPermission.SetOutsideO365Scope(); + TestCustomerNo := 'CUST1'; + TestVendorNo := 'VEND1'; + end; + + // ============================================================================ + // EXERCISE 2.A: Test Send (Enqueue) functionality + // Verifies that the Send procedure correctly: + // - Gets the TempBlob from SendContext + // - Reads JSON content from the blob + // - Creates POST request to /enqueue endpoint + // - Sends the request using HttpClient + // ============================================================================ + [Test] + procedure TestExercise2A_Send() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + ConnectorIntegration: Codeunit "Connector Integration"; + SendContext: Codeunit SendContext; + TempBlob: Codeunit "Temp Blob"; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + HttpHeaders: HttpHeaders; + JsonText: Text; + RequestUri: Text; + begin + Initialize(); + SetupConnectionSetup(); + + // [GIVEN] A JSON document to send + JsonText := GetTestSalesInvoiceJson(); + WriteJsonToBlob(JsonText, TempBlob); + SendContext.SetTempBlob(TempBlob); + + // [WHEN] Calling Send to enqueue the document + asserterror ConnectorIntegration.Send(EDocument, EDocumentService, SendContext); + + // [THEN] The HTTP request should be created correctly + HttpRequest := SendContext.Http().GetHttpRequestMessage(); + HttpResponse := SendContext.Http().GetHttpResponseMessage(); + + // Verify POST method was used + Assert.AreEqual('POST', HttpRequest.Method(), 'Should use POST method'); + + // Verify the endpoint is /enqueue + RequestUri := HttpRequest.GetRequestUri(); + Assert.IsTrue(RequestUri.EndsWith('/enqueue'), 'Should call /enqueue endpoint'); + + // Verify the request has content + HttpRequest.Content.GetHeaders(HttpHeaders); + Assert.IsTrue(HttpHeaders.Contains('Content-Type'), 'Should have Content-Type header'); + end; + + // ============================================================================ + // EXERCISE 2.B: Test ReceiveDocuments (Peek) functionality + // Verifies that the ReceiveDocuments procedure correctly: + // - Creates GET request to /peek endpoint + // - Sends the request using HttpClient + // - Parses the JSON response array + // - Adds each document to DocumentsMetadata + // ============================================================================ + [Test] + procedure TestExercise2B_ReceiveDocuments() + var + EDocumentService: Record "E-Document Service"; + ConnectorIntegration: Codeunit "Connector Integration"; + ReceiveContext: Codeunit ReceiveContext; + DocumentsMetadata: Codeunit "Temp Blob List"; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + RequestUri: Text; + begin + Initialize(); + SetupConnectionSetup(); + + // [WHEN] Calling ReceiveDocuments to peek at available documents + asserterror ConnectorIntegration.ReceiveDocuments(EDocumentService, DocumentsMetadata, ReceiveContext); + + // [THEN] The HTTP request should be created correctly + HttpRequest := ReceiveContext.Http().GetHttpRequestMessage(); + HttpResponse := ReceiveContext.Http().GetHttpResponseMessage(); + + // Verify GET method was used + Assert.AreEqual('GET', HttpRequest.Method(), 'Should use GET method'); + + // Verify the endpoint is /peek + RequestUri := HttpRequest.GetRequestUri(); + Assert.IsTrue(RequestUri.EndsWith('/peek'), 'Should call /peek endpoint'); + end; + + // ============================================================================ + // EXERCISE 2.C: Test DownloadDocument (Dequeue) functionality + // Verifies that the DownloadDocument procedure correctly: + // - Creates GET request to /dequeue endpoint + // - Sends the request using HttpClient + // - Parses the document from JSON response + // - Writes the document to TempBlob in ReceiveContext + // ============================================================================ + [Test] + procedure TestExercise2C_DownloadDocument() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + ConnectorIntegration: Codeunit "Connector Integration"; + ReceiveContext: Codeunit ReceiveContext; + DocumentMetadata: Codeunit "Temp Blob"; + HttpRequest: HttpRequestMessage; + HttpResponse: HttpResponseMessage; + RequestUri: Text; + begin + Initialize(); + SetupConnectionSetup(); + + // [WHEN] Calling DownloadDocument to dequeue a document + asserterror ConnectorIntegration.DownloadDocument(EDocument, EDocumentService, DocumentMetadata, ReceiveContext); + + // [THEN] The HTTP request should be created correctly + HttpRequest := ReceiveContext.Http().GetHttpRequestMessage(); + HttpResponse := ReceiveContext.Http().GetHttpResponseMessage(); + + // Verify GET method was used + Assert.AreEqual('GET', HttpRequest.Method(), 'Should use GET method'); + + // Verify the endpoint is /dequeue + RequestUri := HttpRequest.GetRequestUri(); + Assert.IsTrue(RequestUri.EndsWith('/dequeue'), 'Should call /dequeue endpoint'); + end; + + // ============================================================================ + // HELPER: Verify JSON reading and writing helpers work + // ============================================================================ + [Test] + procedure TestJsonHelpers() + var + ConnectorRequests: Codeunit "Connector Requests"; + TempBlob: Codeunit "Temp Blob"; + JsonText: Text; + ReadText: Text; + begin + Initialize(); + // [GIVEN] Test JSON content + JsonText := GetTestSalesInvoiceJson(); + + // [WHEN] Writing JSON to blob and reading it back + ConnectorRequests.WriteTextToBlob(JsonText, TempBlob); + ReadText := ConnectorRequests.ReadJsonFromBlob(TempBlob); + + // [THEN] Content should match + Assert.AreEqual(JsonText, ReadText, 'JSON content should be preserved'); + end; + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + local procedure SetupConnectionSetup() + var + ConnectorSetup: Record "Connector Connection Setup"; + begin + if not ConnectorSetup.Get() then begin + ConnectorSetup.Init(); + ConnectorSetup."API Base URL" := 'https://test.azurewebsites.net/'; + ConnectorSetup.SetAPIKey('12345678-1234-1234-1234-123456789abc'); + ConnectorSetup.Insert(); + end; + end; + + local procedure GetTestSalesInvoiceJson(): Text + var + JsonObject: JsonObject; + LinesArray: JsonArray; + LineObject: JsonObject; + JsonText: Text; + begin + // Create test JSON that matches the expected format for sales + JsonObject.Add('documentType', 'Invoice'); + JsonObject.Add('documentNo', 'TEST-SI-001'); + JsonObject.Add('customerNo', TestCustomerNo); + JsonObject.Add('customerName', 'Test Customer'); + JsonObject.Add('postingDate', Format(Today(), 0, '--')); + JsonObject.Add('currencyCode', 'USD'); + JsonObject.Add('totalAmount', 1000); + + // Add line + LineObject.Add('lineNo', 10000); + LineObject.Add('type', 'Item'); + LineObject.Add('no', 'ITEM-001'); + LineObject.Add('description', 'Test Item'); + LineObject.Add('quantity', 5); + LineObject.Add('unitPrice', 200); + LineObject.Add('lineAmount', 1000); + LinesArray.Add(LineObject); + JsonObject.Add('lines', LinesArray); + + JsonObject.WriteTo(JsonText); + exit(JsonText); + end; + + local procedure WriteJsonToBlob(JsonText: Text; var TempBlob: Codeunit "Temp Blob") + var + OutStr: OutStream; + begin + TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8); + OutStr.WriteText(JsonText); + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json new file mode 100644 index 00000000..8974ea04 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json @@ -0,0 +1,48 @@ +{ + "id": "12345678-0002-0002-0002-000000000002", + "name": "Directions Connector", + "publisher": "Directions EMEA Workshop", + "version": "1.0.0.0", + "application": "27.1.0.0", + "brief": "Connector integration for E-Document workshop", + "description": "Workshop extension that implements the E-Document integration interfaces to send/receive documents via HTTP API", + "privacyStatement": "https://privacy.microsoft.com", + "EULA": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright", + "help": "https://learn.microsoft.com/dynamics365/business-central/", + "url": "https://github.com/microsoft/BCTech", + "logo": "", + "dependencies": [ + { + "id": "e1d97edc-c239-46b4-8d84-6368bdf67c8b", + "name": "E-Document Core", + "publisher": "Microsoft", + "version": "27.1.0.0" + }, + { + "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14", + "name": "Library Assert", + "publisher": "Microsoft", + "version": "27.1.0.0" + }, + { + "id": "5d86850b-0d76-4eca-bd7b-951ad998e997", + "name": "Tests-TestLibraries", + "publisher": "Microsoft", + "version": "27.1.0.0" + } + ], + "screenshots": [], + "platform": "27.0.0.0", + "idRanges": [ + { + "from": 50121, + "to": 50140 + } + ], + "features": [ + "NoImplicitWith", + "TranslationFile" + ], + "target": "Cloud", + "runtime": "16.0" +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al new file mode 100644 index 00000000..b0ba7bfa --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al @@ -0,0 +1,235 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Format; + +using Microsoft.eServices.EDocument; +using Microsoft.Sales.History; +using Microsoft.Purchases.Document; +using System.Utilities; + +/// +/// Simple JSON Format +/// +codeunit 50102 "SimpleJson Format" implements "E-Document" +{ + Access = Internal; + + // ============================================================================ + // OUTGOING DOCUMENTS + // Exercise 1 + // Validate that required fields are filled before creating the document. + // ============================================================================ + + procedure Check(var SourceDocumentHeader: RecordRef; EDocumentService: Record "E-Document Service"; EDocumentProcessingPhase: Enum "E-Document Processing Phase") + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + begin + case SourceDocumentHeader.Number of + Database::"Sales Invoice Header": + begin + // Validation complete + SourceDocumentHeader.Field(SalesInvoiceHeader.FieldNo("Sell-to Customer No.")).TestField(); + + // TODO: Exercise 1.A: Validate Posting Date + SourceDocumentHeader.Field(SalesInvoiceHeader.FieldNo("Posting Date")).TestField(); + end; + end; + end; + + procedure Create(EDocumentService: Record "E-Document Service"; var EDocument: Record "E-Document"; var SourceDocumentHeader: RecordRef; var SourceDocumentLines: RecordRef; var TempBlob: Codeunit "Temp Blob") + var + OutStr: OutStream; + begin + TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8); + + case EDocument."Document Type" of + EDocument."Document Type"::"Sales Invoice": + CreateSalesInvoiceJson(SourceDocumentHeader, SourceDocumentLines, OutStr); + else + Error('Document type %1 is not supported', EDocument."Document Type"); + end; + end; + + local procedure CreateSalesInvoiceJson(var SourceDocumentHeader: RecordRef; var SourceDocumentLines: RecordRef; var OutStr: OutStream) + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + RootObject: JsonObject; + LinesArray: JsonArray; + LineObject: JsonObject; + JsonText: Text; + begin + // Get the actual records from RecordRef + SourceDocumentHeader.SetTable(SalesInvoiceHeader); + SourceDocumentLines.SetTable(SalesInvoiceLine); + + // Fields + RootObject.Add('documentType', 'Invoice'); + RootObject.Add('documentNo', SalesInvoiceHeader."No."); + RootObject.Add('postingDate', Format(SalesInvoiceHeader."Posting Date", 0, '--')); + RootObject.Add('currencyCode', SalesInvoiceHeader."Currency Code"); + RootObject.Add('totalAmount', Format(SalesInvoiceHeader."Amount Including VAT", 0, 9)); + + // TODO: Exercise 1.B - Fill in customerNo and customerName, vendorNo and VendorName for header + // It is important that you have a vendor with the same number in your system, since when you receive the data you will have to pick the vendor based on the VendorNo + RootObject.Add('customerNo', SalesInvoiceHeader."Sell-to Customer No."); + RootObject.Add('customerName', SalesInvoiceHeader."Sell-to Customer Name"); + + // Hardcoded for simplicity. Normally this would be company information + RootObject.Add('vendorNo', '10000'); + RootObject.Add('vendorName', 'Adatum Corporation'); + + // Create lines array + if SalesInvoiceLine.FindSet() then + repeat + Clear(LineObject); + LineObject.Add('lineNo', SalesInvoiceLine."Line No."); + LineObject.Add('type', Format(SalesInvoiceLine.Type)); + LineObject.Add('no', SalesInvoiceLine."No."); + LineObject.Add('unitPrice', SalesInvoiceLine."Unit Price"); + LineObject.Add('lineAmount', SalesInvoiceLine."Amount Including VAT"); + + // TODO: Exercise 1.B - Fill in description and quantity for line + LineObject.Add('description', SalesInvoiceLine.Description); + LineObject.Add('quantity', SalesInvoiceLine.Quantity); + + LinesArray.Add(LineObject); + until SalesInvoiceLine.Next() = 0; + + RootObject.Add('lines', LinesArray); + + RootObject.WriteTo(JsonText); + OutStr.WriteText(JsonText); + end; + + procedure CreateBatch(EDocumentService: Record "E-Document Service"; var EDocuments: Record "E-Document"; var SourceDocumentHeaders: RecordRef; var SourceDocumentsLines: RecordRef; var TempBlob: Codeunit "Temp Blob") + begin + Error('Batch creation is not implemented in this workshop version'); + end; + + // ============================================================================ + // INCOMING DOCUMENTS + // Exercise 4 + // Parse information from received JSON document. + // ============================================================================ + + procedure GetBasicInfoFromReceivedDocument(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") + var + JsonObject: JsonObject; + JsonToken: JsonToken; + SimpleJsonHelper: Codeunit "SimpleJson Helper"; + begin + if not SimpleJsonHelper.ReadJsonFromBlob(TempBlob, JsonObject) then + Error('Failed to parse JSON document'); + + // Set document type to Purchase Invoice + EDocument."Document Type" := EDocument."Document Type"::"Purchase Invoice"; + + // Extract document number + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'documentNo', JsonToken) then + EDocument."Incoming E-Document No." := CopyStr(SimpleJsonHelper.GetJsonTokenValue(JsonToken), 1, MaxStrLen(EDocument."Incoming E-Document No.")); + + // Extract posting date + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'postingDate', JsonToken) then + EDocument."Document Date" := SimpleJsonHelper.GetJsonTokenDate(JsonToken); + + // Extract currency code + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'currencyCode', JsonToken) then + EDocument."Currency Code" := CopyStr(SimpleJsonHelper.GetJsonTokenValue(JsonToken), 1, MaxStrLen(EDocument."Currency Code")); + + + // TODO: Exercise 2.A - Fill in the vendor information and total amount + + // TODO: Extract vendor number (from "vendorNo" in JSON) + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorNo', JsonToken) then + EDocument."Bill-to/Pay-to No." := SimpleJsonHelper.getJsonTokenValue(JsonToken); + + // TODO: Extract vendor name + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorName', JsonToken) then + EDocument."Bill-to/Pay-to Name" := SimpleJsonHelper.getJsonTokenValue(JsonToken); + + // TODO: Extract total amount + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'totalAmount', JsonToken) then + EDocument."Amount Incl. VAT" := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken); + end; + + procedure GetCompleteInfoFromReceivedDocument(var EDocument: Record "E-Document"; var CreatedDocumentHeader: RecordRef; var CreatedDocumentLines: RecordRef; var TempBlob: Codeunit "Temp Blob") + var + PurchaseHeader: Record "Purchase Header" temporary; + PurchaseLine: Record "Purchase Line" temporary; + JsonObject: JsonObject; + JsonToken: JsonToken; + JsonArray: JsonArray; + JsonLineToken: JsonToken; + SimpleJsonHelper: Codeunit "SimpleJson Helper"; + LineNo: Integer; + begin + if not SimpleJsonHelper.ReadJsonFromBlob(TempBlob, JsonObject) then + Error('Failed to parse JSON document'); + + // Create Purchase Header + PurchaseHeader."Document Type" := PurchaseHeader."Document Type"::Invoice; + PurchaseHeader.InitRecord(); + PurchaseHeader.Insert(true); + + // Set vendor from JSON + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorNo', JsonToken) then + PurchaseHeader.Validate("Buy-from Vendor No.", SimpleJsonHelper.GetJsonTokenValue(JsonToken)); + + // Set posting date + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'postingDate', JsonToken) then + PurchaseHeader.Validate("Posting Date", SimpleJsonHelper.GetJsonTokenDate(JsonToken)); + + // Set currency code (if not blank) + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'currencyCode', JsonToken) then begin + if SimpleJsonHelper.GetJsonTokenValue(JsonToken) <> '' then + PurchaseHeader.Validate("Currency Code", SimpleJsonHelper.GetJsonTokenValue(JsonToken)); + end; + + PurchaseHeader.Modify(true); + + // Create Purchase Lines from JSON array + if JsonObject.Get('lines', JsonToken) then begin + JsonArray := JsonToken.AsArray(); + LineNo := 10000; + + foreach JsonLineToken in JsonArray do begin + JsonObject := JsonLineToken.AsObject(); + + PurchaseLine.Init(); + PurchaseLine."Document Type" := PurchaseHeader."Document Type"; + PurchaseLine."Document No." := PurchaseHeader."No."; + PurchaseLine."Line No." := LineNo; + PurchaseLine.Type := PurchaseLine.Type::Item; + + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'no', JsonToken) then + PurchaseLine."No." := SimpleJsonHelper.GetJsonTokenValue(JsonToken); + + // TODO: Set description + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'description', JsonToken) then + PurchaseLine.Description := SimpleJsonHelper.GetJsonTokenValue(JsonToken); + + // TODO: Set quantity + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'quantity', JsonToken) then + PurchaseLine.Quantity := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken); + + // TODO: Set line amount + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'lineAmount', JsonToken) then + PurchaseLine.Amount := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken); + + // Set unit cost + if SimpleJsonHelper.SelectJsonToken(JsonObject, 'unitCost', JsonToken) then + PurchaseLine."Direct Unit Cost" := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken); + + PurchaseLine.Insert(true); + LineNo += 10000; + end; + end; + + // Return via RecordRef + CreatedDocumentHeader.GetTable(PurchaseHeader); + CreatedDocumentLines.GetTable(PurchaseLine); + end; +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al new file mode 100644 index 00000000..86f1c9fe --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Format; + +using Microsoft.eServices.EDocument; + +/// +/// Extends the E-Document Format enum to add SimpleJson format. +/// This enum value is used to identify which format implementation to use. +/// +enumextension 50100 "SimpleJson Format" extends "E-Document Format" +{ + value(50100; "SimpleJson") + { + Caption = 'Simple JSON'; + Implementation = "E-Document" = "SimpleJson Format"; + } +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al new file mode 100644 index 00000000..c678680e --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al @@ -0,0 +1,116 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Format; + +using System.Utilities; +using Microsoft.eServices.EDocument; + +/// +/// Helper codeunit with pre-written methods for JSON operations. +/// These methods are provided to save time during the workshop. +/// +codeunit 50104 "SimpleJson Helper" +{ + Access = Internal; + + /// + /// Writes a JSON property to the output stream. + /// + /// The output stream to write to. + /// The name of the JSON property. + /// The value of the JSON property. + procedure WriteJsonProperty(var OutStr: OutStream; PropertyName: Text; PropertyValue: Text) + begin + OutStr.WriteText('"' + PropertyName + '": "' + PropertyValue + '"'); + end; + + /// + /// Writes a JSON numeric property to the output stream. + /// + procedure WriteJsonNumericProperty(var OutStr: OutStream; PropertyName: Text; PropertyValue: Decimal) + begin + OutStr.WriteText('"' + PropertyName + '": ' + Format(PropertyValue, 0, 9)); + end; + + /// + /// Gets a text value from a JSON token. + /// + procedure GetJsonTokenValue(JsonToken: JsonToken): Text + var + JsonValue: JsonValue; + begin + if JsonToken.IsValue then begin + JsonValue := JsonToken.AsValue(); + exit(JsonValue.AsText()); + end; + exit(''); + end; + + /// + /// Gets a decimal value from a JSON token. + /// + procedure GetJsonTokenDecimal(JsonToken: JsonToken): Decimal + var + JsonValue: JsonValue; + DecimalValue: Decimal; + begin + if JsonToken.IsValue then begin + JsonValue := JsonToken.AsValue(); + exit(JsonValue.AsDecimal()); + end; + exit(0); + end; + + /// + /// Gets a date value from a JSON token. + /// + procedure GetJsonTokenDate(JsonToken: JsonToken): Date + var + JsonValue: JsonValue; + DateValue: Date; + begin + if JsonToken.IsValue then begin + JsonValue := JsonToken.AsValue(); + if Evaluate(DateValue, JsonValue.AsText()) then + exit(DateValue); + end; + exit(0D); + end; + + /// + /// Selects a JSON token from a JSON object by path. + /// + procedure SelectJsonToken(JsonObject: JsonObject; Path: Text; var JsonToken: JsonToken): Boolean + begin + exit(JsonObject.SelectToken(Path, JsonToken)); + end; + + /// + /// Reads JSON content from a TempBlob into a JsonObject. + /// + procedure ReadJsonFromBlob(var TempBlob: Codeunit "Temp Blob"; var JsonObject: JsonObject): Boolean + var + InStr: InStream; + JsonText: Text; + begin + TempBlob.CreateInStream(InStr, TextEncoding::UTF8); + InStr.ReadText(JsonText); + exit(JsonObject.ReadFrom(JsonText)); + end; + + + [EventSubscriber(ObjectType::Table, Database::"E-Document Log", OnBeforeExportDataStorage, '', false, false)] + local procedure OnBeforeExportDataStorage(EDocumentLog: Record "E-Document Log"; var FileName: Text) + var + EDocumentService: Record "E-Document Service"; + begin + EDocumentService.Get(EDocumentLog."Service Code"); + if EDocumentService."Document Format" <> EDocumentService."Document Format"::"SimpleJson" then + exit; + + FileName := StrSubstNo('%1_%2.json', EDocumentLog."Service Code", EDocumentLog."Document No."); + end; + +} diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al new file mode 100644 index 00000000..35ee3a45 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al @@ -0,0 +1,283 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.EServices.EDocument.Format; + +using System.TestLibraries.Utilities; +using Microsoft.eServices.EDocument; +using Microsoft.Sales.History; +using System.Utilities; +using Microsoft.Purchases.Document; + +/// +/// Simple test runner for workshop participants. +/// Run this codeunit to validate your exercise implementations. +/// +codeunit 50113 "SimpleJson Test" +{ + + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibraryLowerPermission: Codeunit "Library - Lower Permissions"; + TestCustomerNo: Code[20]; + TestVendorNo: Code[20]; + + local procedure Initialize() + begin + LibraryLowerPermission.SetOutsideO365Scope(); + TestCustomerNo := 'CUST1'; + TestVendorNo := 'VEND1'; + end; + + + [Test] + procedure TestExercise1_CheckValidation() + var + EDocumentService: Record "E-Document Service"; + SimpleJsonFormat: Codeunit "SimpleJson Format"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + SourceDocumentHeader: RecordRef; + EDocProcessingPhase: Enum "E-Document Processing Phase"; + begin + Initialize(); + // [GIVEN] A Sales Invoice Header with missing required fields + CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine); + Clear(SalesInvoiceHeader."Posting Date"); // Invalidate required field + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + + // [WHEN] Calling Check method, [THEN] it should fail validation + asserterror SimpleJsonFormat.Check(SourceDocumentHeader, EDocumentService, EDocProcessingPhase::Create); + Assert.ExpectedError('Sell-to Customer No.'); + end; + + + [Test] + procedure TestExercise1_CheckCreate() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + SimpleJsonFormat: Codeunit "SimpleJson Format"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + TempBlob: Codeunit "Temp Blob"; + SourceDocumentHeader: RecordRef; + SourceDocumentLines: RecordRef; + EDocProcessingPhase: Enum "E-Document Processing Phase"; + begin + Initialize(); + // [GIVEN] A Sales Invoice Header with missing required fields + CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + SourceDocumentLines.GetTable(SalesInvoiceLine); + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + + // [WHEN] Calling create method, [THEN] it should succeed + SimpleJsonFormat.Create(EDocumentService, EDocument, SourceDocumentHeader, SourceDocumentLines, TempBlob); + + // [THEN] JSON should be created successfully + VerifyJsonContent(TempBlob, SalesInvoiceHeader, SalesInvoiceLine); + end; + + [Test] + procedure TestExercise2_OutgoingMethodsWork() + var + EDocumentService: Record "E-Document Service"; + EDocument: Record "E-Document"; + SimpleJsonFormat: Codeunit "SimpleJson Format"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + SourceDocumentHeader: RecordRef; + SourceDocumentLines: RecordRef; + TempBlob: Codeunit "Temp Blob"; + EDocProcessingPhase: Enum "E-Document Processing Phase"; + begin + Initialize(); + // [GIVEN] A complete Sales Invoice + CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + SourceDocumentLines.GetTable(SalesInvoiceLine); + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + + // [WHEN/THEN] Check should work (no error) + SimpleJsonFormat.Check(SourceDocumentHeader, EDocumentService, EDocProcessingPhase::Create); + + // [WHEN/THEN] Create should work (no error) + SimpleJsonFormat.Create(EDocumentService, EDocument, SourceDocumentHeader, SourceDocumentLines, TempBlob); + + // [THEN] JSON should be created successfully + VerifyJsonContent(TempBlob, SalesInvoiceHeader, SalesInvoiceLine); + end; + + [Test] + procedure TestExercise2_GetBasicInfoFromJSON() + var + EDocument: Record "E-Document"; + SimpleJsonFormat: Codeunit "SimpleJson Format"; + TempBlob: Codeunit "Temp Blob"; + JsonText: Text; + begin + Initialize(); + // [GIVEN] A JSON document with customer information + JsonText := GetTestPurchaseInvoiceJson(); + WriteJsonToBlob(JsonText, TempBlob); + + // [WHEN] Parsing basic info from JSON + SimpleJsonFormat.GetBasicInfoFromReceivedDocument(EDocument, TempBlob); + + // [THEN] EDocument should have correct basic information + Assert.AreEqual(EDocument."Document Type"::"Purchase Invoice", EDocument."Document Type", 'Should be Purchase Invoice'); + Assert.AreEqual('TEST-PI-001', EDocument."Document No.", 'Document No should match'); + Assert.AreEqual(TestVendorNo, EDocument."Bill-to/Pay-to No.", 'Vendor No should match'); + Assert.AreEqual('Test Vendor', EDocument."Bill-to/Pay-to Name", 'Vendor Name should match'); + Assert.AreEqual(Today(), EDocument."Document Date", 'Document Date should match'); + Assert.AreEqual('USD', EDocument."Currency Code", 'Currency Code should match'); + end; + + [Test] + procedure TestExercise2_CreatePurchaseInvoiceFromJSON() + var + EDocument: Record "E-Document"; + SimpleJsonFormat: Codeunit "SimpleJson Format"; + PurchaseHeader: Record "Purchase Header"; + PurchaseLine: Record "Purchase Line"; + CreatedDocumentHeader: RecordRef; + CreatedDocumentLines: RecordRef; + TempBlob: Codeunit "Temp Blob"; + JsonText: Text; + begin + Initialize(); + // [GIVEN] A JSON document with complete invoice data + JsonText := GetTestPurchaseInvoiceJson(); + WriteJsonToBlob(JsonText, TempBlob); + + // [WHEN] Creating Purchase Invoice from JSON + SimpleJsonFormat.GetCompleteInfoFromReceivedDocument(EDocument, CreatedDocumentHeader, CreatedDocumentLines, TempBlob); + + // [THEN] Purchase Header should be created correctly + CreatedDocumentHeader.SetTable(PurchaseHeader); + Assert.AreEqual(PurchaseHeader."Document Type"::Invoice, PurchaseHeader."Document Type", 'Should be Invoice type'); + Assert.AreEqual(TestVendorNo, PurchaseHeader."Buy-from Vendor No.", 'Vendor should match'); + Assert.AreEqual(Today(), PurchaseHeader."Posting Date", 'Posting Date should match'); + Assert.AreEqual('USD', PurchaseHeader."Currency Code", 'Currency should match'); + + // [THEN] Purchase Lines should be created correctly + CreatedDocumentLines.SetTable(PurchaseLine); + PurchaseLine.SetRange("Document No.", PurchaseHeader."No."); + Assert.IsTrue(PurchaseLine.FindFirst(), 'Should have purchase lines'); + Assert.AreEqual('ITEM-001', PurchaseLine."No.", 'Item No should match'); + Assert.AreEqual('Test Item', PurchaseLine.Description, 'Description should match'); + Assert.AreEqual(5, PurchaseLine.Quantity, 'Quantity should match'); + Assert.AreEqual(200, PurchaseLine."Direct Unit Cost", 'Unit Cost should match'); + end; + + local procedure CreateTestSalesInvoice(var SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; PostingDate: Date) + begin + SalesInvoiceHeader.Init(); + SalesInvoiceHeader."No." := 'TEST-SI-001'; + SalesInvoiceHeader."Sell-to Customer No." := CustomerNo; + SalesInvoiceHeader."Sell-to Customer Name" := 'Test Customer'; + SalesInvoiceHeader."Posting Date" := PostingDate; + SalesInvoiceHeader."Currency Code" := 'USD'; + SalesInvoiceHeader."Amount Including VAT" := 1000; + SalesInvoiceHeader.Insert(); + end; + + local procedure CreateTestSalesInvoiceWithLines(var SalesInvoiceHeader: Record "Sales Invoice Header"; var SalesInvoiceLine: Record "Sales Invoice Line") + begin + CreateTestSalesInvoice(SalesInvoiceHeader, TestCustomerNo, Today()); + + SalesInvoiceLine.Init(); + SalesInvoiceLine."Document No." := SalesInvoiceHeader."No."; + SalesInvoiceLine."Line No." := 10000; + SalesInvoiceLine.Type := SalesInvoiceLine.Type::Item; + SalesInvoiceLine."No." := 'ITEM-001'; + SalesInvoiceLine.Description := 'Test Item'; + SalesInvoiceLine.Quantity := 5; + SalesInvoiceLine."Unit Price" := 200; + SalesInvoiceLine."Amount Including VAT" := 1000; + SalesInvoiceLine.Insert(); + end; + + local procedure VerifyJsonContent(var TempBlob: Codeunit "Temp Blob"; SalesInvoiceHeader: Record "Sales Invoice Header"; SalesInvoiceLine: Record "Sales Invoice Line") + var + InStr: InStream; + JsonText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + JsonArray: JsonArray; + LineObject: JsonObject; + begin + TempBlob.CreateInStream(InStr, TextEncoding::UTF8); + InStr.ReadText(JsonText); + + Assert.IsTrue(JsonObject.ReadFrom(JsonText), 'Should be valid JSON'); + + // Verify header + Assert.IsTrue(JsonObject.Get('documentNo', JsonToken), 'Should have documentNo'); + Assert.AreEqual(SalesInvoiceHeader."No.", JsonToken.AsValue().AsText(), 'DocumentNo should match'); + + Assert.IsTrue(JsonObject.Get('customerName', JsonToken), 'Should have customerName'); + Assert.AreEqual(SalesInvoiceHeader."Sell-to Customer Name", JsonToken.AsValue().AsText(), 'CustomerName should match'); + + Assert.IsTrue(JsonObject.Get('totalAmount', JsonToken), 'Should have totalAmount'); + Assert.AreEqual(SalesInvoiceHeader."Amount Including VAT", JsonToken.AsValue().AsDecimal(), 'TotalAmount should match'); + + // Verify lines + Assert.IsTrue(JsonObject.Get('lines', JsonToken), 'Should have lines'); + JsonArray := JsonToken.AsArray(); + Assert.AreEqual(1, JsonArray.Count(), 'Should have one line'); + + JsonArray.Get(0, JsonToken); + LineObject := JsonToken.AsObject(); + + Assert.IsTrue(LineObject.Get('description', JsonToken), 'Line should have description'); + Assert.AreEqual(SalesInvoiceLine.Description, JsonToken.AsValue().AsText(), 'Line description should match'); + + Assert.IsTrue(LineObject.Get('quantity', JsonToken), 'Line should have quantity'); + Assert.AreEqual(SalesInvoiceLine.Quantity, JsonToken.AsValue().AsDecimal(), 'Line quantity should match'); + end; + + local procedure GetTestPurchaseInvoiceJson(): Text + var + JsonObject: JsonObject; + LinesArray: JsonArray; + LineObject: JsonObject; + JsonText: Text; + begin + // Create test JSON that matches the expected format + JsonObject.Add('documentType', 'Invoice'); + JsonObject.Add('documentNo', 'TEST-PI-001'); + JsonObject.Add('customerNo', TestVendorNo); // Note: In JSON it's customerNo, but we map to vendor + JsonObject.Add('customerName', 'Test Vendor'); + JsonObject.Add('postingDate', Format(Today(), 0, '--')); + JsonObject.Add('currencyCode', 'USD'); + JsonObject.Add('totalAmount', 1000); + + // Add line + LineObject.Add('lineNo', 10000); + LineObject.Add('type', 'Item'); + LineObject.Add('no', 'ITEM-001'); + LineObject.Add('description', 'Test Item'); + LineObject.Add('quantity', 5); + LineObject.Add('unitPrice', 200); + LineObject.Add('lineAmount', 1000); + LinesArray.Add(LineObject); + JsonObject.Add('lines', LinesArray); + + JsonObject.WriteTo(JsonText); + exit(JsonText); + end; + + local procedure WriteJsonToBlob(JsonText: Text; var TempBlob: Codeunit "Temp Blob") + var + OutStr: OutStream; + begin + TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8); + OutStr.WriteText(JsonText); + end; + +} \ No newline at end of file diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json b/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json new file mode 100644 index 00000000..073d53c3 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json @@ -0,0 +1,48 @@ +{ + "id": "12345678-0001-0001-0001-000000000001", + "name": "SimpleJson E-Document Format", + "publisher": "Directions EMEA Workshop", + "version": "1.0.0.0", + "application": "27.1.0.0", + "brief": "SimpleJson format for E-Document workshop", + "description": "Workshop extension that implements the E-Document format interface for JSON documents", + "privacyStatement": "https://privacy.microsoft.com", + "EULA": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright", + "help": "https://learn.microsoft.com/dynamics365/business-central/", + "url": "https://github.com/microsoft/BCTech", + "logo": "", + "dependencies": [ + { + "id": "e1d97edc-c239-46b4-8d84-6368bdf67c8b", + "name": "E-Document Core", + "publisher": "Microsoft", + "version": "27.1.0.0" + }, + { + "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14", + "name": "Library Assert", + "publisher": "Microsoft", + "version": "27.1.0.0" + }, + { + "id": "5d86850b-0d76-4eca-bd7b-951ad998e997", + "name": "Tests-TestLibraries", + "publisher": "Microsoft", + "version": "27.1.0.0" + } + ], + "screenshots": [], + "platform": "27.0.0.0", + "idRanges": [ + { + "from": 50100, + "to": 50150 + } + ], + "features": [ + "TranslationFile", + "NoImplicitWith" + ], + "target": "Cloud", + "runtime": "16.0" +} diff --git a/samples/EDocument/DirectionsEMEA2025/server/README.md b/samples/EDocument/DirectionsEMEA2025/server/README.md new file mode 100644 index 00000000..49b24b50 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/server/README.md @@ -0,0 +1,404 @@ +# Workshop API Server + +Simple FastAPI server for the E-Document Connector Workshop. + +## Overview + +This server provides a simple queue-based document exchange API for workshop participants to practice E-Document integrations. + +**Features**: +- User registration with API keys +- Document queue per user (isolated) +- FIFO queue operations (enqueue, peek, dequeue) +- In-memory storage (no database required) +- Simple authentication via header + +**Technology**: Python FastAPI + +--- + +## Deployment + +### Azure Web App (Recommended for Workshop) + +The server is deployed to Azure App Service for the workshop. + +**Requirements**: +- Azure subscription +- App Service (Linux) +- Python 3.9+ runtime + +**Deployment Steps**: + +1. **Create App Service**: +```bash +az webapp up --name workshop-edocument-api \ + --resource-group rg-workshop \ + --runtime "PYTHON:3.9" \ + --sku B1 +``` + +2. **Deploy Code**: +```bash +cd server +zip -r app.zip . +az webapp deployment source config-zip \ + --resource-group rg-workshop \ + --name workshop-edocument-api \ + --src app.zip +``` + +3. **Configure Startup**: +In Azure Portal, set startup command: +```bash +python -m uvicorn server:app --host 0.0.0.0 --port 8000 +``` + +4. **Verify**: +```bash +curl https://workshop-edocument-api.azurewebsites.net/docs +``` + +--- + +## Local Development + +### Prerequisites +- Python 3.9 or higher +- pip + +### Installation + +1. **Install dependencies**: +```bash +pip install -r requirements.txt +``` + +2. **Run server**: +```bash +python -m uvicorn server:app --reload --port 8000 +``` + +3. **Access**: +- API: http://localhost:8000 +- Interactive docs: http://localhost:8000/docs +- Alternative docs: http://localhost:8000/redoc + +--- + +## API Endpoints + +### POST /register +Register a new user and get an API key. + +**Request**: +```json +{ + "name": "user-name" +} +``` + +**Response**: +```json +{ + "status": "ok", + "key": "uuid-api-key" +} +``` + +### POST /enqueue +Add a document to your queue. + +**Headers**: `X-Service-Key: your-api-key` + +**Request**: Any JSON document + +**Response**: +```json +{ + "status": "ok", + "queued_count": 3 +} +``` + +### GET /peek +View all documents in your queue (without removing). + +**Headers**: `X-Service-Key: your-api-key` + +**Response**: +```json +{ + "queued_count": 2, + "items": [...] +} +``` + +### GET /dequeue +Retrieve and remove the first document from your queue. + +**Headers**: `X-Service-Key: your-api-key` + +**Response**: +```json +{ + "document": {...} +} +``` + +### DELETE /clear +Clear all documents from your queue. + +**Headers**: `X-Service-Key: your-api-key` + +**Response**: +```json +{ + "status": "cleared" +} +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ FastAPI Server │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ auth_keys: Dict[str, str] │ │ user_id -> api_key +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ queues: Dict[str, deque] │ │ api_key -> queue +│ └──────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Data Structures + +**auth_keys**: Maps user names to API keys +```python +{ + "john-doe": "uuid-1", + "jane-smith": "uuid-2" +} +``` + +**queues**: Maps API keys to document queues +```python +{ + "uuid-1": deque([doc1, doc2, doc3]), + "uuid-2": deque([doc4, doc5]) +} +``` + +--- + +## Testing + +### Using cURL + +```bash +# Register +curl -X POST http://localhost:8000/register \ + -H "Content-Type: application/json" \ + -d '{"name": "test-user"}' + +# Enqueue +curl -X POST http://localhost:8000/enqueue \ + -H "X-Service-Key: your-key" \ + -H "Content-Type: application/json" \ + -d '{"test": "document"}' + +# Peek +curl -X GET http://localhost:8000/peek \ + -H "X-Service-Key: your-key" + +# Dequeue +curl -X GET http://localhost:8000/dequeue \ + -H "X-Service-Key: your-key" + +# Clear +curl -X DELETE http://localhost:8000/clear \ + -H "X-Service-Key: your-key" +``` + +### Using Python + +```python +import requests + +base_url = "http://localhost:8000" + +# Register +response = requests.post(f"{base_url}/register", json={"name": "test-user"}) +api_key = response.json()["key"] + +headers = {"X-Service-Key": api_key} + +# Enqueue +requests.post(f"{base_url}/enqueue", headers=headers, json={"test": "doc"}) + +# Peek +response = requests.get(f"{base_url}/peek", headers=headers) +print(response.json()) + +# Dequeue +response = requests.get(f"{base_url}/dequeue", headers=headers) +print(response.json()) +``` + +--- + +## Security Considerations + +⚠️ **WARNING**: This server is designed for workshop use only! + +**Security Limitations**: +- ❌ No HTTPS enforcement +- ❌ No rate limiting +- ❌ No persistent storage +- ❌ Simple API key authentication +- ❌ No data encryption +- ❌ No audit logging +- ❌ All data lost on restart + +**For Production Use**: +- ✅ Use proper authentication (OAuth, JWT) +- ✅ Add HTTPS/TLS encryption +- ✅ Implement rate limiting +- ✅ Use persistent database +- ✅ Add comprehensive logging +- ✅ Implement data retention policies +- ✅ Add monitoring and alerting + +--- + +## Monitoring + +### Check Server Health + +```bash +curl http://localhost:8000/docs +``` + +If the interactive docs load, the server is running. + +### View Logs + +**Local**: +- Check terminal where uvicorn is running + +**Azure**: +```bash +az webapp log tail --name workshop-edocument-api --resource-group rg-workshop +``` + +Or in Azure Portal: +- App Service → Monitoring → Log stream + +--- + +## Troubleshooting + +### Server won't start +- Check Python version: `python --version` (need 3.9+) +- Verify dependencies: `pip install -r requirements.txt` +- Check port availability: `netstat -an | grep 8000` + +### Can't access from outside +- Verify firewall settings +- Check Azure networking rules +- Ensure correct URL (HTTP not HTTPS for local) + +### Authentication errors +- Verify API key is correctly copied +- Check header name: `X-Service-Key` (case-sensitive) +- Re-register if key is lost + +### Queue empty when it shouldn't be +- Remember: Server uses in-memory storage +- Data is lost on restart +- Each user has isolated queue + +--- + +## Scaling Considerations + +For larger workshops: + +1. **Use Azure App Service Plan** with auto-scaling +2. **Monitor CPU/Memory** usage +3. **Consider Redis** for distributed queue if multiple instances needed +4. **Add rate limiting** to prevent abuse +5. **Implement pagination** for large queues + +Current implementation handles ~100 concurrent users with basic App Service plan. + +--- + +## Development Notes + +### Adding New Endpoints + +1. Add route to `server.py`: +```python +@app.get("/new-endpoint") +async def new_endpoint(request: Request): + key = get_key(request) + # Your logic here + return {"status": "ok"} +``` + +2. Test locally +3. Deploy to Azure + +### Modifying Data Structure + +Currently uses: +- `defaultdict(deque)` for queues +- `dict` for auth keys + +For persistence, consider: +- Redis for queue +- Database for auth + +--- + +## API Documentation + +The server provides automatic API documentation: + +- **Swagger UI**: `/docs` +- **ReDoc**: `/redoc` +- **OpenAPI JSON**: `/openapi.json` + +Use these for interactive testing and documentation. + +--- + +## License + +MIT License - See repository root for details. + +--- + +## Support + +For issues with the server: +1. Check this README +2. Review server logs +3. Test endpoints with curl +4. Contact workshop instructor + +For workshop content: +- See main README.md +- Check WORKSHOP_GUIDE.md +- Ask instructor + +--- + +**Happy API Testing!** 🚀 diff --git a/samples/EDocument/DirectionsEMEA2025/server/app.py b/samples/EDocument/DirectionsEMEA2025/server/app.py new file mode 100644 index 00000000..62c993f2 --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/server/app.py @@ -0,0 +1,62 @@ +from flask import Flask, request, jsonify, abort +from collections import defaultdict, deque +import uuid + +app = Flask(__name__) + +# Simple in-memory stores +auth_keys = {} # user_id -> key +queues = defaultdict(deque) # key -> deque + +@app.route("/register", methods=["POST"]) +def register(): + data = request.get_json() + name = data.get("name") + if not name: + abort(400, "Missing name") + key = str(uuid.uuid4()) + auth_keys[name] = key + queues[key] # Initialize queue + return jsonify({"status": "ok", "key": key}) + +def get_key() -> str: + key = str.lower(request.headers.get("X-Service-Key")) + if not key or key not in queues: + abort(401, "Unauthorized or invalid key") + return key + +@app.route("/enqueue", methods=["POST"]) +def enqueue(): + key = get_key() + doc = request.get_json() + queues[key].append(doc) + return jsonify({"status": "ok", "queued_count": len(queues[key])}) + +@app.route("/dequeue", methods=["GET"]) +def dequeue(): + key = get_key() + if not queues[key]: + abort(404, "Queue empty") + value = queues[key].popleft() + return jsonify({"document": value}) + +@app.route("/peek", methods=["GET"]) +def peek(): + key = get_key() + return jsonify({"queued_count": len(queues[key]), "items": list(queues[key])}) + +@app.route("/clear", methods=["DELETE"]) +def clear(): + key = get_key() + queues[key].clear() + return jsonify({"status": "cleared"}) + +@app.route("/") +def root(): + return "Hello world" + +# if __name__ == "__main__": +# app.run(debug=True, host="0.0.0.0", port=8000) + +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file diff --git a/samples/EDocument/DirectionsEMEA2025/server/requirements.txt b/samples/EDocument/DirectionsEMEA2025/server/requirements.txt new file mode 100644 index 00000000..809745ba --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/server/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.0 +werkzeug==3.0.1 \ No newline at end of file diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/API_REFERENCE.md b/samples/EDocument/DirectionsEMEA2025/workshop/API_REFERENCE.md new file mode 100644 index 00000000..e69de29b diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/README.md b/samples/EDocument/DirectionsEMEA2025/workshop/README.md new file mode 100644 index 00000000..3efe07ec --- /dev/null +++ b/samples/EDocument/DirectionsEMEA2025/workshop/README.md @@ -0,0 +1,294 @@ +# E-Document Connector Workshop +## Directions EMEA 2025 + +Welcome to the E-Document Connector Workshop! This hands-on workshop teaches you how to build complete E-Document integrations using the Business Central E-Document Core framework. + +--- + +## 🎯 Workshop Overview + +**Duration**: 90 minutes (10 min intro + 80 min hands-on) + +**What You'll Build**: +1. **SimpleJson Format** - E-Document format implementation for JSON documents +2. **DirectionsConnector** - HTTP API integration for sending/receiving documents +3. **Complete Round-Trip** - Full document exchange between Business Central and external systems + +**What You'll Learn**: +- How the E-Document Core framework works +- How to implement format interfaces +- How to implement integration interfaces +- Best practices for E-Document solutions + +--- + +## 📂 Workshop Structure + +``` +workshop/ +├── COMPLETE_WORKSHOP_GUIDE.md # ⭐ Start here! Complete step-by-step guide +├── API_REFERENCE.md # API endpoint documentation +├── README.md # This file - overview and quick reference + +application/ +├── simple_json/ # Exercise 1: Format implementation +│ ├── SimpleJsonFormat.Codeunit.al # ⚠️ Your code here +│ ├── SimpleJsonFormat.EnumExt.al # ✅ Pre-written +│ ├── SimpleJsonHelper.Codeunit.al # ✅ Helper methods +│ ├── SimpleJsonTest.Codeunit.al # ✅ Automated tests +│ └── app.json +│ +└── directions_connector/ # Exercise 2: Integration implementation + ├── ConnectorIntegration.Codeunit.al # ⚠️ Your code here + ├── ConnectorIntegration.EnumExt.al # ✅ Pre-written + ├── ConnectorAuth.Codeunit.al # ✅ Helper methods + ├── ConnectorRequests.Codeunit.al # ✅ Helper methods + ├── ConnectorConnectionSetup.Table.al # ✅ Setup table + ├── ConnectorConnectionSetup.Page.al # ✅ Setup UI + ├── ConnectorTests.Codeunit.al # ✅ Automated tests + └── app.json + +``` + +--- + +## 🚀 Getting Started + +### For Participants + +**Quick Start:** + +1. **Open** `COMPLETE_WORKSHOP_GUIDE.md` - This is your main guide! +2. **Ensure** you have: + - Business Central development environment + - VS Code with AL Language extension + - API Base URL from instructor +3. **Follow** the guide step-by-step through both exercises +4. **Reference** `API_REFERENCE.md` when working with HTTP endpoints + +**Workshop Timeline:** +- 00:00-00:10: Introduction & Architecture (optional: review `WORKSHOP_INTRO.md`) +- 00:10-00:40: Exercise 1 - SimpleJson Format +- 00:40-01:10: Exercise 2 - DirectionsConnector +- 01:10-01:25: Testing & Live Demo +- 01:25-01:30: Wrap-up & Q&A + +--- + +## ⏱️ Workshop Timeline + +| Time | Duration | Activity | File | +|------|----------|----------|------| +| 00:00-00:10 | 10 min | Introduction & Architecture | WORKSHOP_INTRO.md | +| 00:10-00:40 | 30 min | Exercise 1: SimpleJson Format | WORKSHOP_GUIDE.md | +| 00:40-01:10 | 30 min | Exercise 2: DirectionsConnector | WORKSHOP_GUIDE.md | +| 01:10-01:25 | 15 min | Testing & Live Demo | WORKSHOP_GUIDE.md | +| 01:25-01:30 | 5 min | Wrap-up | - | + +--- + +## 📋 Prerequisites + +### Required +- Business Central development environment (Sandbox or Docker) +- VS Code with AL Language extension +- Basic AL programming knowledge +- API Base URL (provided by instructor) + +### Helpful +- Understanding of REST APIs +- JSON format familiarity +- Postman or similar API testing tool + +--- + +## 🎓 Learning Objectives + +By the end of this workshop, participants will be able to: + +1. ✅ Understand the E-Document Core framework architecture +2. ✅ Implement the "E-Document" interface for custom formats +3. ✅ Implement IDocumentSender and IDocumentReceiver interfaces +4. ✅ Configure and use E-Document Services in Business Central +5. ✅ Test complete document round-trips (send and receive) +6. ✅ Troubleshoot common integration issues +7. ✅ Know where to find resources for further learning + +--- + +## 📚 Key Concepts + +### E-Document Format Interface +Converts Business Central documents to/from external formats: +- **Outgoing**: `Check()`, `Create()` +- **Incoming**: `GetBasicInfoFromReceivedDocument()`, `GetCompleteInfoFromReceivedDocument()` + +### Integration Interfaces +Communicates with external systems: +- **IDocumentSender**: `Send()` - Send documents +- **IDocumentReceiver**: `ReceiveDocuments()`, `DownloadDocument()` - Receive documents + +### SimpleJson Format +- Simple JSON structure for learning +- Header fields: document type, number, customer, date, amount +- Lines array: line items with quantities and prices +- Human-readable and easy to debug + +### Connector +- REST API integration via HTTP +- Authentication via API key header +- Queue-based document exchange +- Stateless and scalable + +--- + +## 📖 Additional Resources + +### Workshop Materials +- [Workshop Introduction](WORKSHOP_INTRO.md) - Slide deck presentation +- [Workshop Guide](WORKSHOP_GUIDE.md) - Detailed instructions with code +- [API Reference](API_REFERENCE.md) - Complete endpoint documentation +- [Workshop Plan](WORKSHOP_PLAN.md) - Implementation overview + +### E-Document Framework +- [E-Document Core README](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/README.md) - Official framework documentation +- [E-Document Interface](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Document/Interfaces/EDocument.Interface.al) - Interface source code + +### External Resources +- [Business Central Documentation](https://learn.microsoft.com/dynamics365/business-central/) +- [AL Language Reference](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-reference-overview) + + +## 🎯 Quick Start + +### 5-Minute Setup + +1. **Get API Access:** + - Instructor provides API Base URL: `https://[server].azurewebsites.net/` + - Open Business Central + - Search "Connector Connection Setup" + - Enter API Base URL and your unique name + - Click "Register" to get API key + +2. **Create E-Document Service:** + - Search "E-Document Services" + - Create new service with: + - Code: `CONNECTOR` + - Document Format: `Simple JSON Format - Exercise 1` + - Service Integration V2: `Connector` + - Enable the service + +2. **Create E-Document Workflow:** + - Create Workflow with E-Document + - When E-document Created -> Send E-Document using setup: `CONNECTOR` + - Enable workflow, and assign it in document sending profile + - Assign document sending profile to Customer. + +3. **Start Coding:** + - Open `application/simple_json/SimpleJsonFormat.Codeunit.al` + - Find first TODO comment + - Follow `COMPLETE_WORKSHOP_GUIDE.md` + +### Quick Test + +**Outgoing (Send):** +``` +1. Post a Sales Invoice +2. Open E-Documents → Find your invoice +3. Click "Send" +4. Check status changes to "Sent" +``` + +**Incoming (Receive):** +``` +1. E-Document Services → Select CONNECTOR +2. Click "Receive" +3. New E-Documents arrive +``` + +--- + +## 📖 Documentation Quick Reference + +| Document | Purpose | When to Use | +|----------|---------|-------------| +| [**COMPLETE_WORKSHOP_GUIDE.md**](./COMPLETE_WORKSHOP_GUIDE.md) | ⭐ Main guide with all exercises and solutions | Start here - your primary reference | +| [**API_REFERENCE.md**](./API_REFERENCE.md) | Complete API endpoint documentation | When implementing HTTP calls | + +--- + +## 🏆 Success Criteria + +You've completed the workshop successfully if you can: + +- ✅ Post a Sales Invoice and see it as an E-Document +- ✅ View the JSON content in the E-Document log +- ✅ Send the E-Document to the API +- ✅ Verify the document appears in the API queue (using `/peek`) +- ✅ Receive documents from the API queue +- ✅ Create Purchase Invoices from received E-Documents +- ✅ Verify all data is mapped correctly (vendor, dates, lines, amounts) +- ✅ Understand the complete round-trip flow + +--- + +## 🔧 Code Locations + +### Exercise 1: SimpleJson Format + +**File:** `application/simple_json/SimpleJsonFormat.Codeunit.al` + +**TODOs:** +- Line ~30: `Check()` - Add Posting Date validation +- Line ~93: `CreateSalesInvoiceJson()` - Add customer fields to header +- Line ~110: `CreateSalesInvoiceJson()` - Add description and quantity to lines +- Line ~165: `GetBasicInfoFromReceivedDocument()` - Parse vendor info and amount +- Line ~195: `GetCompleteInfoFromReceivedDocument()` - Set vendor and dates +- Line ~215: `GetCompleteInfoFromReceivedDocument()` - Set line details + +### Exercise 2: DirectionsConnector + +**File:** `application/directions_connector/ConnectorIntegration.Codeunit.al` + +**TODOs:** +- Line ~45: `Send()` - Get TempBlob and read JSON +- Line ~50: `Send()` - Create POST request to enqueue +- Line ~55: `Send()` - Send HTTP request +- Line ~85: `ReceiveDocuments()` - Create GET request to peek +- Line ~90: `ReceiveDocuments()` - Send HTTP request +- Line ~105: `ReceiveDocuments()` - Add documents to metadata list +- Line ~120: `DownloadDocument()` - Create GET request to dequeue +- Line ~125: `DownloadDocument()` - Send HTTP request + +--- + + +## 💡 Tips for Success + +1. **Read TODO comments carefully** - They contain important hints +2. **Use the helper methods** - Pre-written functions save time +3. **Test incrementally** - Don't wait until everything is done +4. **Check logs** - E-Document logs show JSON and HTTP details +5. **Use API_REFERENCE.md** - Complete endpoint documentation +6. **Ask questions** - Instructor is here to help! +7. **Collaborate** - Exchange documents with other participants + +--- + +## 📚 Learning Resources + +### E-Document Framework +- **Core README**: [E-Document Core Documentation](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/README.md) +- **Interface Source**: [E-Document Interface Code](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Document/Interfaces/EDocument.Interface.al) +- **Integration Interfaces**: [Integration Interface Code](https://github.com/microsoft/BCApps/tree/main/src/Apps/W1/EDocument/App/src/Integration/Interfaces) + +### Business Central Development +- [BC Developer Documentation](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/) +- [AL Language Reference](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-reference-overview) +- [AL Samples Repository](https://github.com/microsoft/AL) + +--- + +**Happy Coding!** 🚀 + +*For questions or feedback, contact the workshop instructor.* diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/WORKSHOP_GUIDE.md b/samples/EDocument/DirectionsEMEA2025/workshop/WORKSHOP_GUIDE.md new file mode 100644 index 00000000..e69de29b diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png b/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png new file mode 100644 index 00000000..4b66d8ae Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png differ diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/image.png b/samples/EDocument/DirectionsEMEA2025/workshop/image.png new file mode 100644 index 00000000..3fbcaf88 Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/image.png differ diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/service.png b/samples/EDocument/DirectionsEMEA2025/workshop/service.png new file mode 100644 index 00000000..76ca79f8 Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/service.png differ diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png b/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png new file mode 100644 index 00000000..7f28b8f4 Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png differ