Codeunit 6153 API Webhook Notification Mgt., source in 29
Source29
src/Layers/W1/BaseApp/System/API/Webhooks/APIWebhookNotificationMgt.Codeunit.al913 lines, Copyright (c) Microsoft Corporation. MIT
namespace Microsoft.API.Webhooks;
using Microsoft.Integration.Dataverse;
using Microsoft.Integration.Graph;
using System.DataAdministration;
using System.Environment.Configuration;
using System.Integration;
using System.Security.AccessControl;
using System.Threading;
using System.Utilities;
codeunit 6153 "API Webhook Notification Mgt."
{
// Registers notifications in table API Webhook Notification on entity insert, modify, rename and delete
SingleInstance = true;
Permissions = TableData "API Webhook Subscription" = rimd,
TableData "API Webhook Notification" = rimd,
TableData "API Webhook Notification Aggr" = rimd;
InherentEntitlements = X;
InherentPermissions = X;
trigger OnRun()
begin
end;
var
GraphMgtGeneralTools: Codeunit "Graph Mgt - General Tools";
APIWebhookCategoryLbl: Label 'AL API Webhook', Locked = true;
JobQueueCategoryCodeLbl: Label 'APIWEBHOOK', Locked = true;
JobQueueCategoryDescLbl: Label 'Send API Webhook Notifications';
CreateNotificationMsg: Label 'Create new notification. Subscription: %1. Expiration time: %2. Table: %3. Last modified time: %4. Change type: %5. Notification ID: %6.', Locked = true;
CannotCreateNotificationErr: Label 'Cannot create new notification. Subscription: %1. Expiration time: %2. Table: %3. Last modified time: %4. Change type: %5.', Locked = true;
ZeroSystemIdTxt: Label 'The record has zero system ID. Subscription: %1. Expiration time: %2. Table: %3. Last modified time: %4. Change type: %5.', Locked = true;
FilterMatchingMsg: Label 'The record in table %3 is matching the filter in %4 %5. Subscription: %1. Expiration time: %2', Locked = true;
FilterMismatchingMsg: Label 'The record in table %3 is mismatching the filter in %4 %5. Subscription: %1. Expiration time: %2', Locked = true;
DeleteSubscriptionMsg: Label 'Delete subscription. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
DeleteObsoleteOrUnsupportedSubscriptionMsg: Label 'Delete subscription for an obsolete or unsupported entity. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
UnsupportedFieldTypeErr: Label 'The %1 field in the %2 table is of an unsupported type.', Locked = true;
ChangeTypeOption: Option Created,Updated,Deleted,Collection;
CachedApiSubscriptionEnabled: Boolean;
CachedDetailedLoggingEnabled: Boolean;
FindingEntityMsg: Label 'Finding entity for subscription. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
CannotFindEntityErr: Label 'Cannot find entity. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
TemporarySourceTableErr: Label 'No support for entities with a temporary source table. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
JobQueueEntrySourceTableErr: Label 'No support for entities with source table Job Queue Entry. Subscription: %1. Expiration time: %2. Table: %3.', Locked = true;
CompositeEntityKeyErr: Label 'No support for entities with a composite key. Subscription: %1. Expiration time: %2. Table: %3. Fields: %4.', Locked = true;
IncorrectEntityKeyErr: Label 'Incorrect entity key. Subscription: %1. Expiration time: %2. Table: %3. Fields: %4. ', Locked = true;
ScheduleJobMsg: Label 'Schedule job. Processing time: %1. Earliest start time: %2. Latest start time: %3.', Locked = true;
ReadyJobExistsMsg: Label 'Ready job exists. Earliest start time: %1.', Locked = true;
CreateJobCategoryMsg: Label 'Create new job category.', Locked = true;
CreateJobMsg: Label 'Create new job. Earliest start time: %1.', Locked = true;
DeleteHangingJobMsg: Label 'Delete hanging job. Earliest start time: %1.', Locked = true;
FailedDeletingAPIWebhooksLbl: Label 'Failed deleting API Webhooks. Error Code %1, Error message: %2', Locked = true;
UseCachedApiSubscriptionEnabled: Boolean;
UseCachedDetailedLoggingEnabled: Boolean;
TooManyJobsMsg: Label 'New job is not created. Count of jobs cannot exceed %1.', Locked = true;
NoPermissionsTxt: Label 'No permissions.', Locked = true;
TooManyNotificationsTxt: Label 'Too many notifications', Locked = true;
FieldTok: Label 'Field', Locked = true;
EqConstTok: Label '=CONST(', Locked = true;
EqFilterTok: Label '=FILTER(', Locked = true;
CachedTotalCountOfExistingNotifications: Boolean;
TotalCountOfExistingNotifications: Integer;
[Scope('OnPrem')]
procedure OnDatabaseInsert(var RecRef: RecordRef)
begin
ProcessSubscriptions(RecRef, ChangeTypeOption::Created);
end;
[Scope('OnPrem')]
procedure OnDatabaseModify(var RecRef: RecordRef)
begin
ProcessSubscriptions(RecRef, ChangeTypeOption::Updated);
end;
[Scope('OnPrem')]
procedure OnDatabaseDelete(var RecRef: RecordRef)
begin
ProcessSubscriptions(RecRef, ChangeTypeOption::Deleted);
end;
[Scope('OnPrem')]
procedure OnDatabaseRename(var RecRef: RecordRef; var xRecRef: RecordRef)
begin
ProcessSubscriptionsOnRename(RecRef, xRecRef);
end;
local procedure ProcessSubscriptions(var RecRef: RecordRef; ChangeType: Option)
var
APIWebhookSubscription: Record "API Webhook Subscription";
ScheduleJobQueue: Boolean;
EarliestStartDateTime: DateTime;
begin
if RecRef.IsTemporary then
exit;
if not GetSubscriptions(APIWebhookSubscription, RecRef.Number) then
exit;
repeat
if ProcessSubscription(RecRef, APIWebhookSubscription, ChangeType) then
ScheduleJobQueue := true;
until APIWebhookSubscription.Next() = 0;
if ScheduleJobQueue then begin
EarliestStartDateTime := CurrentDateTime();
ScheduleJob(EarliestStartDateTime);
end;
end;
local procedure ProcessSubscription(var RecRef: RecordRef; var APIWebhookSubscription: Record "API Webhook Subscription"; ChangeType: Option): Boolean
var
ApiWebhookEntity: Record "Api Webhook Entity";
begin
if not GetEntity(APIWebhookSubscription, ApiWebhookEntity) then begin
Session.LogMessage('000024M', StrSubstNo(DeleteObsoleteOrUnsupportedSubscriptionMsg, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if CheckTableFilters(APIWebhookSubscription, ApiWebhookEntity, RecRef) then
exit(RegisterNotification(ApiWebhookEntity, APIWebhookSubscription, RecRef, ChangeType));
exit(false);
end;
local procedure ProcessSubscriptionsOnRename(var RecRef: RecordRef; var xRecRef: RecordRef)
var
APIWebhookSubscription: Record "API Webhook Subscription";
ScheduleJobQueue: Boolean;
EarliestStartDateTime: DateTime;
begin
if RecRef.IsTemporary then
exit;
if not GetSubscriptions(APIWebhookSubscription, RecRef.Number) then
exit;
repeat
if ProcessSubscriptionOnRename(RecRef, xRecRef, APIWebhookSubscription) then
ScheduleJobQueue := true;
until APIWebhookSubscription.Next() = 0;
if ScheduleJobQueue then begin
EarliestStartDateTime := CurrentDateTime();
ScheduleJob(EarliestStartDateTime);
end;
end;
local procedure ProcessSubscriptionOnRename(var RecRef: RecordRef; var xRecRef: RecordRef; var APIWebhookSubscription: Record "API Webhook Subscription"): Boolean
var
ApiWebhookEntity: Record "Api Webhook Entity";
RegisteredNotificationDeleted: Boolean;
RegisteredNotificationCreated: Boolean;
RecordSystemId: Guid;
begin
if not GetEntity(APIWebhookSubscription, ApiWebhookEntity) then begin
Session.LogMessage('000024N', StrSubstNo(DeleteObsoleteOrUnsupportedSubscriptionMsg, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
RecordSystemId := RecRef.Field(RecRef.SystemIdNo).Value();
if IsNullGuid(RecordSystemId) then
RecordSystemId := xRecRef.Field(RecRef.SystemIdNo).Value();
if ApiWebhookEntity."OData Key Specified" then begin
if not CheckTableFilters(APIWebhookSubscription, ApiWebhookEntity, RecRef) then
exit(false);
exit(RegisterNotification(ApiWebhookEntity, APIWebhookSubscription, RecRef, ChangeTypeOption::Updated, RecordSystemId));
end;
if CheckTableFilters(APIWebhookSubscription, ApiWebhookEntity, xRecRef) then
RegisteredNotificationDeleted :=
RegisterNotification(ApiWebhookEntity, APIWebhookSubscription, xRecRef, ChangeTypeOption::Deleted, RecordSystemId);
if CheckTableFilters(APIwebhookSubscription, ApiWebhookEntity, RecRef) then
RegisteredNotificationCreated :=
RegisterNotification(ApiWebhookEntity, APIWebhookSubscription, RecRef, ChangeTypeOption::Created, RecordSystemId);
exit(RegisteredNotificationDeleted or RegisteredNotificationCreated);
end;
local procedure GetSubscriptions(var APIWebhookSubscription: Record "API Webhook Subscription"; TableId: Integer): Boolean
var
CDSIntegrationMgt: Codeunit "CDS Integration Mgt.";
begin
if not IsApiSubscriptionEnabled() then
exit(false);
if APIWebhookSubscription.IsEmpty() then
exit(false);
APIWebhookSubscription.SetFilter("Expiration Date Time", '>%1', CurrentDateTime());
APIWebhookSubscription.SetRange("Source Table Id", TableId);
APIWebhookSubscription.SetFilter("Company Name", '%1|%2', CompanyName(), '');
if not CDSIntegrationMgt.IsBusinessEventsEnabled() then
APIWebhookSubscription.SetRange("Subscription Type", APIWebhookSubscription."Subscription Type"::Regular);
exit(APIWebhookSubscription.FindSet());
end;
[Scope('OnPrem')]
procedure GetDatabaseTableTriggerSetup(TableId: Integer; var OnDatabaseInsert: Boolean; var OnDatabaseModify: Boolean; var OnDatabaseDelete: Boolean; var OnDatabaseRename: Boolean)
var
APIWebhookSubscription: Record "API Webhook Subscription";
GraphMgtGeneralTools: Codeunit "Graph Mgt - General Tools";
Enabled: Boolean;
begin
if OnDatabaseDelete and OnDatabaseInsert and OnDatabaseModify and OnDatabaseRename then
exit;
if not GraphMgtGeneralTools.IsApiSubscriptionEnabled() then
exit;
APIWebhookSubscription.SetFilter("Expiration Date Time", '>%1', CurrentDateTime());
APIWebhookSubscription.SetFilter("Company Name", '%1|%2', CompanyName, '');
APIWebhookSubscription.SetRange("Source Table Id", TableID);
Enabled := not APIWebhookSubscription.IsEmpty();
if not Enabled then
exit;
OnDatabaseRename := true;
OnDatabaseModify := true;
OnDatabaseInsert := true;
OnDatabaseDelete := true;
end;
[Scope('OnPrem')]
procedure GetEntity(var APIWebhookSubscription: Record "API Webhook Subscription"; var ApiWebhookEntity: Record "Api Webhook Entity"): Boolean
begin
if IsDetailedLoggingEnabled() then
Session.LogMessage('00006ZN', StrSubstNo(FindingEntityMsg, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
APIWebhookSubscription."Source Table Id"), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
if APIWebhookSubscription."Source Table Id" = Database::"Job Queue Entry" then begin
Session.LogMessage('0000HE3', StrSubstNo(JobQueueEntrySourceTableErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
APIWebhookSubscription."Source Table Id"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
ApiWebhookEntity.SetRange(Publisher, APIWebhookSubscription."Entity Publisher");
ApiWebhookEntity.SetRange(Group, APIWebhookSubscription."Entity Group");
ApiWebhookEntity.SetRange(Version, APIWebhookSubscription."Entity Version");
ApiWebhookEntity.SetRange(Name, APIWebhookSubscription."Entity Set Name");
ApiWebhookEntity.SetRange("Table No.", APIWebhookSubscription."Source Table Id");
if not ApiWebhookEntity.FindFirst() then begin
Session.LogMessage('000029S', StrSubstNo(CannotFindEntityErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
APIWebhookSubscription."Source Table Id"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if ApiWebhookEntity."Table Temporary" then begin
Session.LogMessage('000029T', StrSubstNo(TemporarySourceTableErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
APIWebhookSubscription."Source Table Id"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if StrPos(ApiWebhookEntity."Key Fields", ',') > 0 then begin
Session.LogMessage('000029U', StrSubstNo(CompositeEntityKeyErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
APIWebhookSubscription."Source Table Id", ApiWebhookEntity."Key Fields"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
exit(true);
end;
local procedure CheckTableFilters(var APIWebhookSubscription: Record "API Webhook Subscription"; var ApiWebhookEntity: Record "Api Webhook Entity"; var RecRef: RecordRef): Boolean
var
TempBlob: Codeunit "Temp Blob";
TempRecRef: RecordRef;
FiltersInStream: InStream;
TableFilters: Text;
begin
TempBlob.FromRecord(ApiWebhookEntity, ApiWebhookEntity.FieldNo("Table Filters"));
TempBlob.CreateInStream(FiltersInStream, TEXTENCODING::UTF8);
FiltersInStream.Read(TableFilters);
if TableFilters <> '' then begin
TempRecRef.Open(RecRef.Number, true);
TempRecRef.Init();
CopyPrimaryKeyFields(RecRef, TempRecRef);
CopyFilterFields(RecRef, TempRecRef, TableFilters);
TempRecRef.Insert();
TempRecRef.SetView(TableFilters);
if TempRecRef.IsEmpty() then begin
Session.LogMessage('00006ZO', StrSubstNo(FilterMismatchingMsg, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
RecRef.Number, ApiWebhookEntity."Object Type", ApiWebhookEntity."Object ID"), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
end;
if IsDetailedLoggingEnabled() then
Session.LogMessage('00006ZP', StrSubstNo(FilterMatchingMsg, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
RecRef.Number, ApiWebhookEntity."Object Type", ApiWebhookEntity."Object ID"), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(true);
end;
local procedure CopyPrimaryKeyFields(var FromRecRef: RecordRef; var ToRecRef: RecordRef)
var
FromFieldRef: FieldRef;
KeyRef: KeyRef;
I: Integer;
begin
KeyRef := FromRecRef.KeyIndex(1);
for I := 1 to KeyRef.FieldCount() do begin
FromFieldRef := KeyRef.FieldIndex(I);
CopyFieldValue(FromFieldRef, ToRecRef);
end;
end;
local procedure CopyFilterFields(var FromRecRef: RecordRef; var ToRecRef: RecordRef; TableFilters: Text)
var
FromFieldRef: FieldRef;
RemainingTableFilters: Text;
FieldNoTxt: Text;
FieldNo: Integer;
FieldTokLen: Integer;
Pos: Integer;
EqPos: Integer;
I: Integer;
N: Integer;
begin
FieldTokLen := StrLen(FieldTok);
N := StrLen(TableFilters);
RemainingTableFilters := TableFilters;
for I := 0 to N do
if StrLen(RemainingTableFilters) > 0 then begin
Pos := StrPos(RemainingTableFilters, FieldTok);
if Pos > 0 then begin
RemainingTableFilters := CopyStr(RemainingTableFilters, Pos + FieldTokLen);
EqPos := StrPos(RemainingTableFilters, EqConstTok);
// At least one digit must be before "=" sign
if EqPos < 2 then
EqPos := StrPos(RemainingTableFilters, EqFilterTok);
// Integer max value is 2,147,483,647 so no more then Text[10]
if (EqPos > 1) and (EqPos < 12) then begin
FieldNoTxt := CopyStr(RemainingTableFilters, 1, EqPos - 1);
if Evaluate(FieldNo, FieldNoTxt) then
if FromRecRef.FieldExist(FieldNo) then begin
FromFieldRef := FromRecRef.Field(FieldNo);
CopyFieldValue(FromFieldRef, ToRecRef);
end;
end;
end;
end else
I := N;
end;
local procedure CopyFieldValue(var FromFieldRef: FieldRef; var ToRecordRef: RecordRef)
var
ToFieldRef: FieldRef;
begin
if FromFieldRef.Class = FieldClass::Normal then begin
ToFieldRef := ToRecordRef.Field(FromFieldRef.Number);
ToFieldRef.Value := FromFieldRef.Value();
end;
end;
[Scope('OnPrem')]
procedure DeleteSubscription(var APIWebhookSubscription: Record "API Webhook Subscription")
var
APIWebhookNotification: Record "API Webhook Notification";
APIWebhookNotificationAggr: Record "API Webhook Notification Aggr";
[SecurityFiltering(SecurityFilter::Ignored)]
APIWebhookSubscription2: Record "API Webhook Subscription";
[SecurityFiltering(SecurityFilter::Ignored)]
APIWebhookNotification2: Record "API Webhook Notification";
[SecurityFiltering(SecurityFilter::Ignored)]
APIWebhookNotificationAggr2: Record "API Webhook Notification Aggr";
begin
Session.LogMessage('00006ZQ', StrSubstNo(DeleteSubscriptionMsg, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id"), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
if (not APIWebhookSubscription2.WritePermission()) or
(not APIWebhookNotification2.WritePermission()) or
(not APIWebhookNotificationAggr2.WritePermission()) then begin
Session.LogMessage('0000DY1', NoPermissionsTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit;
end;
APIWebhookNotification.SetRange("Subscription ID", APIWebhookSubscription."Subscription Id");
if not APIWebhookNotification.IsEmpty() then begin
ForceDeleteAPIWebhookNotifications(APIWebhookNotification);
Commit();
end;
APIWebhookNotificationAggr.SetRange("Subscription ID", APIWebhookSubscription."Subscription Id");
if not APIWebhookNotificationAggr.IsEmpty() then begin
APIWebhookNotificationAggr.DeleteAll(true);
Commit();
end;
if not APIWebhookSubscription.Delete() then;
end;
local procedure RegisterNotification(var ApiWebhookEntity: Record "Api Webhook Entity"; var APIWebhookSubscription: Record "API Webhook Subscription"; var RecRef: RecordRef; ChangeType: Option): Boolean
var
SavedRecordRef: RecordRef;
RecordSystemId: Guid;
begin
RecordSystemId := RecRef.Field(RecRef.SystemIdNo).Value();
if IsNullGuid(RecordSystemId) then
if ChangeType = ChangeTypeOption::Updated then begin
SavedRecordRef.Open(RecRef.Number);
CopyPrimaryKeyFields(RecRef, SavedRecordRef);
if SavedRecordRef.Find() then
RecordSystemId := SavedRecordRef.Field(SavedRecordRef.SystemIdNo).Value();
end;
exit(RegisterNotification(ApiWebhookEntity, APIWebhookSubscription, RecRef, ChangeType, RecordSystemId));
end;
procedure ForceDeleteAPIWebhookNotifications(var APIWebhookNotification: Record "API Webhook Notification")
var
I: Integer;
NumberOfRetries: Integer;
WaitBetweenDeletes: Integer;
Success: Boolean;
begin
if APIWebhookNotification.IsEmpty() then
exit;
NumberOfRetries := 5;
WaitBetweenDeletes := 1000;
for I := 1 to NumberOfRetries do begin
ClearLastError();
Commit();
OnDeleteAPIWebhookNotifications(APIWebhookNotification, Success);
if Success then
exit;
Session.LogMessage('0000HRE', StrSubstNo(FailedDeletingAPIWebhooksLbl, GetLastErrorCode(), GetLastErrorText()), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', APIWebhookCategoryLbl);
Sleep(WaitBetweenDeletes);
end;
ClearLastError();
end;
local procedure RegisterNotification(var ApiWebhookEntity: Record "Api Webhook Entity"; var APIWebhookSubscription: Record "API Webhook Subscription"; var RecRef: RecordRef; ChangeType: Option; RecordSystemId: Guid): Boolean
var
TotalAPIWebhookNotification: Record "API Webhook Notification";
APIWebhookNotification: Record "API Webhook Notification";
[SecurityFiltering(SecurityFilter::Ignored)]
APIWebhookNotification2: Record "API Webhook Notification";
APIWebhookNotificationSend: Codeunit "API Webhook Notification Send";
FieldValue: Text;
begin
if not APIWebhookNotification2.WritePermission() then begin
Session.LogMessage('0000DY2', NoPermissionsTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if IsNullGuid(RecordSystemId) then begin
Session.LogMessage('0000FMO', StrSubstNo(ZeroSystemIdTxt, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id",
DateTimeToString(APIWebhookNotification."Last Modified Date Time"), APIWebhookNotification."Change Type"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if not CachedTotalCountOfExistingNotifications then begin
if not TotalAPIWebhookNotification.IsEmpty() then
TotalCountOfExistingNotifications := TotalAPIWebhookNotification.Count()
else
Clear(TotalCountOfExistingNotifications);
CachedTotalCountOfExistingNotifications := true;
end;
if TotalCountOfExistingNotifications > APIWebhookNotificationSend.GetMaxNumberOfLoggedNotifications() then begin
Session.LogMessage('0000HF4', TooManyNotificationsTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if TryGetEntityKeyValue(APIWebhookSubscription, ApiWebhookEntity, RecRef, FieldValue, RecordSystemId) then begin
APIWebhookNotification.SetRange("Subscription ID");
APIWebhookNotification.ID := Guid.CreateSequentialGuid();
APIWebhookNotification."Subscription ID" := APIWebhookSubscription."Subscription Id";
APIWebhookNotification."Created By User SID" := UserSecurityId();
APIWebhookNotification."Change Type" := ChangeType;
// Cannot use $systemModifiedAt as it is not updated yet in OnDatabaseModify
APIWebhookNotification."Last Modified Date Time" := CurrentDateTime();
APIWebhookNotification."Entity ID" := RecordSystemId;
APIWebhookNotification."Entity Key Value" := CopyStr(FieldValue, 1, MaxStrLen(APIWebhookNotification."Entity Key Value"));
if APIWebhookNotification.Insert(true) then begin
TotalCountOfExistingNotifications += 1;
if IsDetailedLoggingEnabled() then
Session.LogMessage('000024P', StrSubstNo(CreateNotificationMsg, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id", DateTimeToString(APIWebhookNotification."Last Modified Date Time"),
APIWebhookNotification."Change Type", APIWebhookNotification.ID), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(true);
end;
end;
Session.LogMessage('000029L', StrSubstNo(CannotCreateNotificationErr, APIWebhookSubscription.SystemId,
DateTimeToString(APIWebhookSubscription."Expiration Date Time"), APIWebhookSubscription."Source Table Id",
DateTimeToString(APIWebhookNotification."Last Modified Date Time"), APIWebhookNotification."Change Type"), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
[Scope('OnPrem')]
procedure TryGetEntityKeyField(var APIWebhookSubscription: Record "API Webhook Subscription"; var ApiWebhookEntity: Record "Api Webhook Entity"; var RecRef: RecordRef; var FieldRef: FieldRef): Boolean
var
FieldNo: Integer;
begin
if StrPos(ApiWebhookEntity."Key Fields", ',') > 0 then begin
Session.LogMessage('000029M', StrSubstNo(CompositeEntityKeyErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
RecRef.Number, ApiWebhookEntity."Key Fields"), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
if not Evaluate(FieldNo, ApiWebhookEntity."Key Fields") then begin
Session.LogMessage('000029N', StrSubstNo(IncorrectEntityKeyErr, APIWebhookSubscription.SystemId, DateTimeToString(APIWebhookSubscription."Expiration Date Time"),
RecRef.Number, ApiWebhookEntity."Key Fields"), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
FieldRef := RecRef.Field(FieldNo);
exit(true);
end;
local procedure TryGetEntityKeyValue(var APIWebhookSubscription: Record "API Webhook Subscription"; var ApiWebhookEntity: Record "Api Webhook Entity"; var RecRef: RecordRef; var FieldValue: Text; RecordSystemId: Guid): Boolean
var
FieldRef: FieldRef;
begin
if not TryGetEntityKeyField(APIWebhookSubscription, ApiWebhookEntity, RecRef, FieldRef) then
exit(false);
if FieldRef.Number = RecRef.SystemIdNo then begin
FieldValue := LowerCase(GraphMgtGeneralTools.GetIdWithoutBrackets(RecordSystemId));
exit(true);
end;
if not GetRawFieldValue(FieldRef, FieldValue) then
exit(false);
exit(true);
end;
local procedure GetRawFieldValue(var FieldRef: FieldRef; var Value: Text): Boolean
var
Date: Date;
Time: Time;
DateTime: DateTime;
BigInt: BigInteger;
Decimal: Decimal;
Bool: Boolean;
Guid: Guid;
ErrorMessage: Text;
begin
case FieldRef.Type of
FieldType::GUID:
begin
Guid := FieldRef.Value();
Value := LowerCase(GraphMgtGeneralTools.GetIdWithoutBrackets(Guid));
end;
FieldType::Code, FieldType::Text:
begin
Value := FieldRef.Value();
if Value <> '' then
Value := Format(FieldRef.Value);
end;
FieldType::Option:
Value := Format(FieldRef);
FieldType::Integer, FieldType::BigInteger:
Value := Format(FieldRef.Value);
FieldType::Boolean:
begin
Bool := FieldRef.Value();
Value := SetBoolFormat(Bool);
end;
FieldType::Date:
begin
Date := FieldRef.Value();
Value := SetDateFormat(Date);
end;
FieldType::Time:
begin
Time := FieldRef.Value();
Value := SetTimeFormat(Time);
end;
FieldType::DateTime:
begin
DateTime := FieldRef.Value();
Value := SetDateTimeFormat(DateTime);
end;
FieldType::Duration:
begin
BigInt := FieldRef.Value();
// Use round to avoid conversion errors due to the conversion from decimal to long.
BigInt := Round(BigInt / 60000, 1);
Value := Format(BigInt);
end;
FieldType::DateFormula:
Value := Format(FieldRef.Value);
FieldType::Decimal:
begin
Decimal := FieldRef.Value();
Value := SetDecimalFormat(Decimal);
end;
else begin
ErrorMessage := StrSubstNo(UnsupportedFieldTypeErr, FieldRef.Caption, FieldRef.Record().Caption);
Session.LogMessage('000029O', ErrorMessage, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit(false);
end;
end;
exit(true);
end;
local procedure SetDateFormat(InDate: Date) OutDate: Text
begin
OutDate := Format(InDate, 0, '<Year4>-<Month,2>-<Day,2>');
end;
local procedure SetTimeFormat(InTime: Time) OutTime: Text
begin
OutTime := ConvertStr(Format(InTime, 0, '<Hours24,2>:<Minutes,2>:<Seconds,2>'), ' ', '0');
end;
local procedure SetDateTimeFormat(InDateTime: DateTime) OutDateTime: Text
begin
if InDateTime = 0DT then
OutDateTime := SetDateFormat(0D) + 'T' + SetTimeFormat(0T) + 'Z'
else
OutDateTime := SetDateFormat(DT2Date(InDateTime)) + 'T' + SetTimeFormat(DT2Time(InDateTime)) + 'Z';
end;
local procedure SetDecimalFormat(InDecimal: Decimal) OutDecimal: Text
begin
OutDecimal := Format(InDecimal, 0, '<Sign>') + Format(InDecimal, 0, '<Integer>');
if CopyStr(Format(InDecimal, 0, '<Decimals>'), 2) <> '' then
OutDecimal := OutDecimal + '.' + CopyStr(Format(InDecimal, 0, '<Decimals>'), 2)
else
OutDecimal := OutDecimal + '.0';
end;
local procedure SetBoolFormat(InBoolean: Boolean) OutBoolean: Text
begin
if InBoolean then
OutBoolean := 'true'
else
OutBoolean := 'false';
end;
[Scope('OnPrem')]
procedure ScheduleJob(EarliestStartDateTime: DateTime)
var
JobQueueEntry: Record "Job Queue Entry";
ProcessingDateTime: DateTime;
LatestStartDateTime: DateTime;
begin
ProcessingDateTime := CurrentDateTime();
LatestStartDateTime := EarliestStartDateTime + GetDelayTime();
if IsDetailedLoggingEnabled() then
Session.LogMessage('000070M', StrSubstNo(ScheduleJobMsg, DateTimeToString(ProcessingDateTime), DateTimeToString(EarliestStartDateTime), DateTimeToString(LatestStartDateTime)), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
if not CanScheduleJob() then begin
Session.LogMessage('0000EWY', NoPermissionsTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit;
end;
JobQueueEntry.SetLoadFields(ID, "Earliest Start Date/Time", Scheduled, "System Task ID");
JobQueueEntry.ReadIsolation := JobQueueEntry.ReadIsolation::ReadUnCommitted;
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", CODEUNIT::"API Webhook Notification Send");
JobQueueEntry.SetRange("Job Queue Category Code", JobQueueCategoryCodeLbl);
JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Ready);
JobQueueEntry.SetRange("Earliest Start Date/Time", EarliestStartDateTime, LatestStartDateTime);
if JobQueueEntry.FindFirst() then begin
JobQueueEntry.CalcFields(Scheduled);
if JobQueueEntry.Scheduled then begin
Session.LogMessage('000070O', StrSubstNo(ReadyJobExistsMsg, DateTimeToString(JobQueueEntry."Earliest Start Date/Time")), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit;
end;
end;
JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::"In Process", JobQueueEntry.Status::Ready);
JobQueueEntry.SetRange("Earliest Start Date/Time");
if JobQueueEntry.Count() >= GetMaxNumberOfJobs() then
if not DeleteHangingJob() then begin
Session.LogMessage('000070P', StrSubstNo(TooManyJobsMsg, GetMaxNumberOfJobs()), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
exit;
end;
CreateJob(LatestStartDateTime);
end;
internal procedure CanScheduleJob(): Boolean
var
JobQueueEntry: Record "Job Queue Entry";
[SecurityFiltering(SecurityFilter::Ignored)]
JobQueueEntry2: Record "Job Queue Entry";
User: Record User;
Handled: Boolean;
CanCreateTask: Boolean;
begin
if not (JobQueueEntry2.WritePermission() and JobQueueEntry.ReadPermission()) then
exit(false);
if not (JobQueueEntry.HasRequiredPermissions()) then
exit(false);
OnCanCreateTask(Handled, CanCreateTask);
if Handled then
exit(CanCreateTask);
if not TASKSCHEDULER.CanCreateTask() then
exit(false);
if not User.Get(UserSecurityId()) then
exit(false);
if User."License Type" = User."License Type"::"Limited User" then
exit(false);
exit(true);
end;
local procedure DeleteHangingJob(): Boolean
var
JobQueueEntry: Record "Job Queue Entry";
JobQueueEntryToDelete: Record "Job Queue Entry";
Deleted: Boolean;
begin
// Delete hanging jobs as they block starting other jobs of the same category. A job is hanging when
// the task scheduler no longer tracks it (Scheduled = false), or when it is stuck in status Ready with
// an Earliest Start Date/Time well in the past. Iterating over both In Process and Ready entries (instead
// of only the first In Process entry) ensures the category can self-heal once such zombie jobs accumulate.
JobQueueEntry.SetLoadFields(Scheduled, Status, "Earliest Start Date/Time");
JobQueueEntry.SetRange("Job Queue Category Code", JobQueueCategoryCodeLbl);
JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::"In Process", JobQueueEntry.Status::Ready);
if JobQueueEntry.FindSet() then
repeat
if IsHangingJob(JobQueueEntry) then begin
Session.LogMessage('0000EF4', StrSubstNo(DeleteHangingJobMsg, DateTimeToString(JobQueueEntry."Earliest Start Date/Time")), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
JobQueueEntryToDelete := JobQueueEntry;
JobQueueEntryToDelete.Delete();
Deleted := true;
end;
until JobQueueEntry.Next() = 0;
exit(Deleted);
end;
local procedure IsHangingJob(var JobQueueEntry: Record "Job Queue Entry"): Boolean
begin
// An In Process entry that the task scheduler no longer tracks (Scheduled = false) will never run
// again on its own. For Ready entries Scheduled = false just means the scheduler has not picked the
// task up yet, which is normal and must not be treated as hanging.
if JobQueueEntry.Status = JobQueueEntry.Status::"In Process" then begin
JobQueueEntry.CalcFields(Scheduled);
if not JobQueueEntry.Scheduled then
exit(true);
end;
// A Ready entry stuck well past its earliest start time is also considered hanging.
if (JobQueueEntry.Status = JobQueueEntry.Status::Ready) and
(JobQueueEntry."Earliest Start Date/Time" <> 0DT) and
(JobQueueEntry."Earliest Start Date/Time" + GetStuckReadyJobThreshold() < CurrentDateTime())
then
exit(true);
exit(false);
end;
local procedure GetStuckReadyJobThreshold(): Duration
begin
// A Ready job whose Earliest Start Date/Time is more than six hours in the past is considered stuck.
// The threshold is kept well above the worst-case backlog: jobs are scheduled every 30 minutes and up
// to the maximum number of jobs can be queued, so a genuinely healthy job can legitimately wait a couple
// of hours before running. Six hours leaves ample margin so only truly stuck jobs are reclaimed.
exit(6 * 60 * 60 * 1000);
end;
local procedure CreateJob(EarliestStartDateTime: DateTime)
var
JobQueueEntry: Record "Job Queue Entry";
begin
if EarliestStartDateTime = 0DT then
EarliestStartDateTime := CurrentDateTime() + GetDelayTime();
Session.LogMessage('00006ZR', StrSubstNo(CreateJobMsg, DateTimeToString(EarliestStartDateTime)), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
SetJobParameters(JobQueueEntry, EarliestStartDateTime);
CODEUNIT.Run(CODEUNIT::"Job Queue - Enqueue", JobQueueEntry);
end;
local procedure SetJobParameters(var JobQueueEntry: Record "Job Queue Entry"; EarliestStartDateTime: DateTime)
begin
CreateApiWebhookJobCategoryIfMissing();
JobQueueEntry."Object Type to Run" := JobQueueEntry."Object Type to Run"::Codeunit;
JobQueueEntry."Object ID to Run" := CODEUNIT::"API Webhook Notification Send";
JobQueueEntry."Job Queue Category Code" :=
CopyStr(JobQueueCategoryCodeLbl, 1, MaxStrLen(JobQueueEntry."Job Queue Category Code"));
JobQueueEntry."Earliest Start Date/Time" := EarliestStartDateTime;
JobQueueEntry."Recurring Job" := false;
JobQueueEntry."Maximum No. of Attempts to Run" := 2;
JobQueueEntry."Rerun Delay (sec.)" := GetDelayTime() div 1000;
end;
local procedure CreateApiWebhookJobCategoryIfMissing()
var
JobQueueCategory: Record "Job Queue Category";
begin
if not JobQueueCategory.Get(JobQueueCategoryCodeLbl) then begin
Session.LogMessage('00006ZS', CreateJobCategoryMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', APIWebhookCategoryLbl);
JobQueueCategory.Validate(Code, CopyStr(JobQueueCategoryCodeLbl, 1, MaxStrLen(JobQueueCategory.Code)));
JobQueueCategory.Validate(Description, CopyStr(JobQueueCategoryDescLbl, 1, MaxStrLen(JobQueueCategory.Description)));
JobQueueCategory.Insert(true);
end;
end;
local procedure GetDelayTime(): Integer
var
ServerSetting: Codeunit "Server Setting";
Handled: Boolean;
DelayTime: Integer;
begin
OnGetDelayTime(Handled, DelayTime);
if Handled then
exit(DelayTime);
DelayTime := ServerSetting.GetApiSubscriptionDelayTime();
exit(DelayTime);
end;
[IntegrationEvent(false, false)]
local procedure OnGetDelayTime(var Handled: Boolean; var Value: Integer)
begin
end;
[Scope('OnPrem')]
procedure IsDetailedLoggingEnabled(): Boolean
var
Handled: Boolean;
Enabled: boolean;
begin
if UseCachedDetailedLoggingEnabled then
exit(CachedDetailedLoggingEnabled);
OnGetDetailedLoggingEnabled(Handled, Enabled);
if Handled then begin
CachedDetailedLoggingEnabled := Enabled;
UseCachedDetailedLoggingEnabled := true;
exit(Enabled);
end;
exit(false);
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"API Webhook Notification Mgt.", 'OnDeleteAPIWebhookNotifications', '', false, false)]
local procedure HandleDeleteAPIWebhookNotification(var ApiWebhookNotification: Record "API Webhook Notification"; var Success: Boolean)
begin
ApiWebhookNotification.DeleteAll();
Success := true;
end;
[InternalEvent(false, true)]
local procedure OnDeleteAPIWebhookNotifications(var ApiWebhookNotification: Record "API Webhook Notification"; var Success: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnGetDetailedLoggingEnabled(var Handled: Boolean; var Enabled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnCanCreateTask(var Handled: Boolean; var CanCreateTask: Boolean)
begin
end;
local procedure IsApiSubscriptionEnabled(): Boolean
var
GraphMgtGeneralTools: Codeunit "Graph Mgt - General Tools";
begin
if UseCachedApiSubscriptionEnabled then
exit(CachedApiSubscriptionEnabled);
CachedApiSubscriptionEnabled := GraphMgtGeneralTools.IsApiSubscriptionEnabled();
UseCachedApiSubscriptionEnabled := true;
exit(CachedApiSubscriptionEnabled);
end;
procedure Reset()
begin
UseCachedApiSubscriptionEnabled := false;
UseCachedDetailedLoggingEnabled := false;
Clear(CachedApiSubscriptionEnabled);
Clear(CachedDetailedLoggingEnabled);
end;
procedure GetMaxNumberOfJobs(): Integer
begin
exit(20);
end;
local procedure DateTimeToString(Value: DateTime): Text
begin
exit(Format(Value, 0, '<Year4>-<Month,2>-<Day,2> <Hours24>:<Minutes,2>:<Seconds,2><Second dec.><Comma,.>'));
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Environment Cleanup", 'OnClearDatabaseConfig', '', false, false)]
local procedure HandleOnClearCompanyConfig(SourceEnv: Enum "Environment Type"; DestinationEnv: Enum "Environment Type")
begin
CleanupAPIWebhookSetup();
end;
local procedure CleanupAPIWebhookSetup()
var
APIWebhookSubscription: Record "API Webhook Subscription";
APIWebhookNotification: Record "API Webhook Notification";
APIWebhookNotificationAggr: Record "API Webhook Notification Aggr";
begin
APIWebhookSubscription.DeleteAll();
Commit();
APIWebhookNotification.DeleteAll();
Commit();
APIWebhookNotificationAggr.DeleteAll();
Commit();
end;
}