Advanced Portal Designer Documentation
Index

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

  1. Open the Advanced Portal Designer menu page (search for it in Tell Me) and click Actions
  2. Click New
  3. Enter the Web Action code (e.g., ATTACHMENT.UPLOAD)
  4. Set Type to "Codeunit"
  5. In the Object No. field, enter or select the codeunit ID
  6. The Object Name field auto-fills
  7. 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:

  1. Codeunit processes data
  2. Codeunit sets session values
  3. Redirect to a File action
  4. 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