Codeunit Action
Codeunit actions execute AL codeunits to handle requests with full programming capabilities. This action type provides maximum flexibility for complex business logic.
Overview
A Codeunit action:
- Runs an AL codeunit when the action is requested
- Passes the session record to the codeunit
- Allows full access to Business Central APIs
- Can generate any type of output (HTML, JSON, files, redirects)
When to Use Codeunit Actions
Use Codeunit actions for:
- File uploads and downloads
- Complex business logic requiring AL code
- Operations with security validation
- Integration with external systems
- Multi-step transactions
- Any scenario beyond template capabilities
Creating a Codeunit Action
Step-by-Step
- Open the Advanced Portal Designer menu page (search for it in Tell Me) and click Actions
- Click New
- Enter the Web Action code (e.g.,
ATTACHMENT.UPLOAD) - Set Type to "Codeunit"
- In the Object No. field, enter or select the codeunit ID
- The Object Name field auto-fills
- Save the action
Multiple Actions, One Codeunit
A single codeunit can handle multiple actions by checking the ACTION parameter:
CASE UPPERCASE(APD.GetParameter(Rec, 'ACTION')) OF
'ATTACHMENT.UPLOAD':
UploadAttachment(Rec);
'ATTACHMENT.DOWNLOAD':
DownloadAttachment(Rec);
'NOTES.ADD':
AddNote(Rec);
ELSE
APD.HttpHeader(0, APDPath + '?_errormessage=Unknown action', '', '', '');
END;
Complete Codeunit Example
Here's a complete, production-ready example codeunit:
codeunit 58500 "APD Actions"
{
TableNo = "APD Session Hgd";
trigger OnRun()
begin
Setup.GET();
Setup.TESTFIELD("Serverpath ( /wsfn/ )");
APDPath := Setup."Serverpath ( /wsfn/ )";
// Check PARAMETER1 for inline codeunit calls from templates
// (Called via <?codeunit('inline','parm1','parm2')?> tag)
CASE APD.GetParameter(Rec, 'PARAMETER1') OF
'INLINE':
begin
// Return value for inline template usage
APD.StreamWrite(APD.GetParameter(Rec, 'PARAMETER2'));
end;
'':
// These are for Actions (Type = Codeunit)
CASE UPPERCASE(APD.GetParameter(Rec, 'ACTION')) OF
'ATTACHMENT.UPLOAD':
UploadAttachment(Rec);
'ATTACHMENT.DOWNLOAD':
DownloadAttachment(Rec);
'NOTES.ADD':
AddNote(Rec);
'NOTES.EDIT':
EditNote(Rec);
ELSE
APD.HttpHeader(0, APDPath + '?_errormessage=Unknown action ' +
APD.GetParameter(Rec, 'ACTION'), '', '', '');
END;
end;
end;
var
Setup: Record "APD Setup Hgd";
APD: Codeunit "APD Worker Hgd";
APDPath: Text[250];
HttpHeader: Option Redirect,"Raw File","PDF Convertion","File Redirect";
}
Dual-Purpose Codeunits
The same codeunit can serve two purposes:
1. Action-Based Calls
When the action type is Codeunit, PARAMETER1 is empty and ACTION contains the action code:
URL: ?action=ATTACHMENT.UPLOAD
→ APD.GetParameter(Rec, 'ACTION') = 'ATTACHMENT.UPLOAD'
→ APD.GetParameter(Rec, 'PARAMETER1') = ''
2. Inline Template Calls
When called from a template via <?codeunit?> tag, up to five user-supplied arguments are passed in as URL parameters parameter1..parameter5 (read with APD.GetParameter(Session, 'parameter1') etc.):
<?codeunit(58500,'INLINE','Hello World')?>
→ APD.GetParameter(Rec, 'PARAMETER1') = 'INLINE'
→ APD.GetParameter(Rec, 'PARAMETER2') = 'Hello World'
Production usage commonly passes a command-name plus several field values:
<?codeunit(73007,'HOURS',field(167,1),field(1001,2),userid())?>
→ APD.GetParameter(Rec, 'PARAMETER1') = 'HOURS'
→ APD.GetParameter(Rec, 'PARAMETER2') = (Job No.)
→ APD.GetParameter(Rec, 'PARAMETER3') = (Job Task No.)
→ APD.GetParameter(Rec, 'PARAMETER4') = (user id)
After the inline call returns, APD clears parameter1..parameter5 so they don't leak into other tags on the page.
Session Record
The session record provides access to:
| Field | Description |
|---|---|
| SessionID | Unique session identifier |
| UserID | Logged-in portal user ID |
| "User Authenticated" | Whether user is logged in |
| Company | User's assigned company |
| Language | User's language preference |
| "Security Profiles filter" | User's security profiles |
| "Navision type" | User type (Customer, Vendor, etc.) |
| "Navision number" | BC entity number (Customer No., Vendor No., etc.) |
| "Uploaded File" | BLOB field for file uploads |
| "Uploaded File Name" | Name of uploaded file |
APD Worker Functions
The APD Worker Hgd codeunit provides essential functions for codeunit actions:
Getting Parameters
var
APD: Codeunit "APD Worker Hgd";
begin
// Get string parameter
OrderNo := APD.GetParameter(Session, 'OrderNo');
// Get integer parameter
DocID := APD.GetParameterInteger(Session, 'DocID');
end;
Writing Output
// Write text/HTML to response
APD.StreamWrite('<h1>Hello World</h1>');
// Write binary data (for file downloads)
APD.StreamBlob(InStream, BlobLength);
HTTP Headers and Redirects
var
HttpHeader: Option Redirect,"Raw File","PDF Convertion","File Redirect";
begin
// Redirect to another action
APD.HttpHeader(0, '/wsfn/?action=HOME', '', '', '');
// Redirect with error message
APD.HttpHeader(0, '/wsfn/?_errormessage=Something went wrong', '', '', '');
// File download with custom headers
APD.HttpHeader(HttpHeader::"Raw File",
'application/octet-stream', // MIME type
'attachment;filename=document.pdf', // Content-Disposition
'', '');
end;
HttpHeader Options
| Option | Use Case |
|---|---|
0 (Redirect) |
Redirect to another URL/action |
"Raw File" |
Serve binary file for download |
"PDF Convertion" |
Convert and serve as PDF |
"File Redirect" |
Redirect to file |
Example: Add Note to Sales Order
This example shows proper security validation:
local procedure AddNote(var Session: Record "APD Session Hgd")
var
RecLinkMgt: Codeunit "Record Link Management";
RecordLink: Record "Record Link";
SalesHeader: Record "Sales Header";
begin
// Get the order
if not SalesHeader.Get(SalesHeader."Document Type"::Order,
APD.GetParameter(Session, 'OrderNo')) then
error('Unknown Sales Order (%1)', APD.GetParameter(Session, 'OrderNo'));
// IMPORTANT: Validate user has access to this order
// APD does NOT automatically validate data access in codeunits!
if not Session."User Authenticated" then
error('Authentication required');
if Session."Navision number" <> SalesHeader."Sell-to Customer No." then
error('Permission error, you do not have access to this sales order');
// User validated - proceed with operation
RecordLink.Init();
RecordLink.Insert(true);
RecordLink."Record ID" := SalesHeader.RecordID;
RecordLink.Type := RecordLink.Type::Note;
RecLinkMgt.WriteNote(RecordLink, APD.GetParameter(Session, 'Text'));
RecordLink.Modify();
end;
Example: File Download
Serve a document attachment as a file download:
local procedure DownloadAttachment(var Session: Record "APD Session Hgd")
var
Document: Record "Document Attachment";
SalesHeader: Record "Sales Header";
TempBlob: Codeunit "Temp Blob";
OutS: OutStream;
InS: InStream;
begin
// Find the document
Document.Setrange("Table ID", DATABASE::"Sales Header");
Document.Setrange("No.", APD.GetParameter(Session, 'OrderNo'));
Document.Setrange("Document Type", Document."Document Type"::Order);
Document.Setrange(ID, APD.GetParameterInteger(Session, 'DocID'));
if not Document.FindFirst() then
error('Unknown Document');
// Get the parent record for security check
if not SalesHeader.Get(SalesHeader."Document Type"::Order, Document."No.") then
error('Permission error, you do not have access to this document');
// Validate user access
if not Session."User Authenticated" or
(Session."Navision number" <> SalesHeader."Sell-to Customer No.") then
error('Permission error, you do not have access to this document');
// Stream the file to the response
TempBlob.CreateOutStream(OutS);
Document."Document Reference ID".ExportStream(OutS);
TempBlob.CreateInStream(InS);
APD.StreamBlob(InS, TempBlob.Length());
// Set headers for file download
APD.HttpHeader(HttpHeader::"Raw File",
'application/octet-stream',
'attachment;filename=' + Document."File Name" + '.' + Document."File Extension",
'', '');
end;
Example: File Upload
Handle file uploads from HTML forms:
local procedure UploadAttachment(Session: Record "APD Session Hgd")
var
PurchHeader: Record "Purchase Header";
Document: Record "Document Attachment";
InS: InStream;
NameParts: List of [Text];
begin
// Get the target record (using table:field notation for form fields)
if not PurchHeader.Get(PurchHeader."Document Type"::Order,
APD.GetParameter(Session, '38:3')) then
error('Unknown order, cannot upload');
// Process the uploaded file
Session."Uploaded File".CreateInStream(InS);
Document.Init();
Document."Table ID" := DATABASE::"Purchase Header";
Document."No." := PurchHeader."No.";
Document."Document Type" := Document."Document Type"::Order;
Document.ImportFromStream(InS, Session."Uploaded File Name");
// Parse filename and extension
NameParts := Session."Uploaded File Name".Split('.');
Document."File Name" := Session."Uploaded File Name".Substring(1,
StrLen(Session."Uploaded File Name") - 1 - StrLen(NameParts.Get(NameParts.Count)));
Document."File Extension" := NameParts.Get(NameParts.Count());
Document.Insert(true);
// Redirect back to the order page
APD.HttpHeader(0, APDPath + '?action=purchaseorder&38:1=1&38:3=' + PurchHeader."No.", '', '', '');
end;
HTML Form for File Upload
<form method="post" enctype="multipart/form-data" action="<?action(ATTACHMENT.UPLOAD)?>">
<input type="hidden" name="38:3" value="<?field(No.)?>">
<input type="file" name="uploadfile" required>
<button type="submit">Upload</button>
</form>
Security Considerations
CRITICAL: There is NO built-in validation on data in codeunits! Unlike templates where security filters automatically apply, codeunits have full access to all data. You MUST validate access yourself.
Always Validate User Access
A malicious user could pass any parameter value. Always verify the user has access:
local procedure EditNote(var Session: Record "APD Session Hgd")
var
RecordLink: Record "Record Link";
SalesHeader: Record "Sales Header";
begin
// Get the note
if not RecordLink.Get(APD.GetParameterInteger(Session, 'NoteID')) then
error('Unknown Note (%1)', APD.GetParameter(Session, 'NoteID'));
// Get the parent record
if not SalesHeader.Get(RecordLink."Record ID") then
error('Permission error, you do not have access to this sales order');
// CRITICAL: Validate user owns this record!
// Without this check, any user could edit any note by guessing NoteID
if not Session."User Authenticated" then
error('Authentication required');
if Session."Navision number" <> SalesHeader."Sell-to Customer No." then
error('Permission error, you do not have access to this sales order');
// Now safe to proceed...
end;
Security Validation Pattern
Use this pattern for all data operations:
// 1. Check authentication
if not Session."User Authenticated" then
error('Authentication required');
// 2. Get the record
if not MyRecord.Get(APD.GetParameter(Session, 'ID')) then
error('Record not found');
// 3. Validate ownership/access
if Session."Navision number" <> MyRecord."Customer No." then
error('Permission error, you do not have access to this record');
// 4. Proceed with operation
// ...
Why This Matters
Consider a customer portal where users view their orders:
URL: ?action=ORDER.VIEW&OrderNo=1001
Without validation, a user could change OrderNo=1001 to OrderNo=1002 and see another customer's order. Always validate!
Use Transactions
Wrap multi-record operations in transactions:
procedure CreateOrder(var Session: Record "APD Session Hgd")
begin
// Transactions are automatic in AL
// Use Commit() only when necessary
// Create header
// Create lines
// All committed together or rolled back on error
end;
Best Practices
Single Responsibility
Each codeunit should do one thing well:
- Create separate codeunits for different operations
- Use helper procedures for common tasks
- Keep main trigger simple
Error Handling
Provide meaningful error messages:
trigger OnRun()
begin
if not TryProcessRequest(Rec) then
HandleError(GetLastErrorText());
end;
[TryFunction]
local procedure TryProcessRequest(var Session: Record "APD Session Hgd")
begin
// Your logic that might fail
end;
Logging
Consider logging for debugging:
procedure ProcessRequest(var Session: Record "APD Session Hgd")
begin
LogActivity(Session.UserID, 'Processing request');
// ...
end;
Combining with Templates
You can call a codeunit and still serve a template:
- Codeunit processes data
- Codeunit sets session values
- Redirect to a File action
- Template uses the processed data
Troubleshooting
Codeunit Not Found
- Verify codeunit number exists
- Check codeunit is published
- Ensure no compilation errors
Permissions Error
- Check BC permissions for the codeunit
- Verify table access permissions
- Review security profile settings
No Output
- Verify output is being generated
- Check MIME type setting
- Test the codeunit independently