Call an IBM i (AS/400) Program in C# (.NET) with NTi

Introduction

This tutorial shows how to call an IBM i (AS/400) program from a C# (.NET) application using NTi Data Provider.

Calling an IBM i program from a .NET application lets you reuse existing business logic (RPG, COBOL, CL) without rewriting it. Legacy programs integrate as they are into modern applications.

With NTi, from C# code you can:

  • define input, output and input/output parameters with NTiProgramParameter
  • call an IBM i program with CallProgramAsync (or CallProgram synchronously)
  • read the returned data straight from the output buffers

💡 IBM i system APIs are ordinary programs from the caller's point of view: everything below also applies to QWCRSVAL, QUSLOBJ and friends. See Call a system API.

NTi works from IBM i V5R4 onward (V7R4 or later recommended). Program calls are available synchronously and, with NTi 5.0.0, as true end-to-end async.


Program and parameter description

The program MYPGM from library MYLIB expects the following parameters:

Description Type Direction Value
Text 1 CHAR(10) Input Hello
Text 2 CHAR(10) Input World
Start position BYTE(1) Input 0x00
Return variable length BYTE(4) Input 128
Return variable CHAR(*) Output empty
Error code CHAR(50) InputOutput empty

The return variable is structured as follows:

Offset Length Description
0 64 Message 1
64 64 Message 2

The call is made with the following code:

Step 1 - Open the connection

Declare a NTiConnection instance and open the connection. With NTi 5, async is the normal path: every operation has a true async variant, cancellable through a CancellationToken.

using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

💡 Connection pooling is off by default: add pooling=true to the connection string for web applications. The synchronous path remains available (conn.Open()).


Step 2 - Create the parameters

Referring to the parameter description above, create the parameter list with their values:

var parms = new List
{
    new NTiProgramParameter("Hello", 10).AsInput(),          // CHAR(10) INPUT
    new NTiProgramParameter("World", 10).AsInput(),          // CHAR(10) INPUT
    new NTiProgramParameter(new byte[] { 0x00 }).AsInput(),  // BYTE(1)  INPUT
    new NTiProgramParameter(128).AsInput(),                  // BYTE(4)  INPUT
    new NTiProgramParameter("", 128).AsOutput(),             // CHAR(128) OUTPUT
    new NTiProgramParameter("", 50)                          // CHAR(50)  INPUT/OUTPUT
};

The default direction is InputOutput (see ParameterDirection); the .AsInput(), .AsOutput() and .AsInputOutput() helpers set it fluently.


Step 3 - Call the program

Call the program using the CallProgramAsync() method of NTiConnection:

await conn.CallProgramAsync("MYLIB", "MYPGM", parms);

Synchronously: conn.CallProgram("MYLIB", "MYPGM", parms);. A cancellation token can be passed as the last argument; by contract, cancelling mid-call breaks the connection (the in-flight frame is lost): expect an OperationCanceledException carrying your token, then reopen the connection.


Step 4 - Retrieve the returned data

Once the program has been called, retrieve the data from the return variable (parameter #5):

string message1 = parms[4].GetString(0, 64);
string message2 = parms[4].GetString(64, 64);

GetString decodes the output buffer with the job CCSID; pass an explicit CCSID (GetString(offset, length, ccsid)) when the !, [, ] or ^ characters matter: they vary across EBCDIC code pages.


Variable size receiver (CHAR(*), VARCHAR)

When the length written by the program is not known in advance (a parameter declared CHAR(*) on the RPG side, a VARCHAR return, a variable size data structure), pass an EMPTY parameter: new NTiProgramParameter(). NTi declares it to the server as variable length (0xFFFF) and the parameter comes back sized to the data the program actually wrote: GetBytes().Length gives the real size. This behavior is available since NTi 4.4.14 and native in 5.x.

using System;
using System.Collections.Generic;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

var receiver = new NTiProgramParameter().AsInputOutput();   // empty receiver: variable size
var parms = new List { receiver };

await conn.CallProgramAsync("MYLIB", "MYPGM2", parms);

// VARCHAR: BINARY(2) length prefix, then the data
string value = receiver.GetString(2, receiver.GetShort());
Console.WriteLine(value);

For a VARCHAR return, the buffer starts with a 2 byte length prefix: GetShort() reads it, and GetString(2, length) decodes the text that follows. A CHAR field located inside a data structure is read with GetString(offset, length).


NTiProgramParameter constructors and accessors

Every constructor accepts an optional trailing ParameterDirection (default: InputOutput):

Constructor Resulting IBM i type
NTiProgramParameter(string value, int length) and (string, int, int ccsid) CHAR(length), blank padded
NTiProgramParameter(string[] values, int length) and (string[], int, int ccsid) CHAR(length) array (IEnumerable<string> overloads too)
NTiProgramParameter(int value) / (int[] values) BINARY(4) / array
NTiProgramParameter(short value) / (short[] values) BINARY(2) / array
NTiProgramParameter(decimal value, int precision, int scale, bool packed = true) packed or zoned decimal
NTiProgramParameter(byte[] value) raw bytes, sent as is
NTiProgramParameter() EMPTY variable size receiver (see above)

The fluent Append(...) overloads extend a parameter with additional fields to compose a data structure in a single buffer; each returns the parameter, so calls chain: Append(string, int[, int ccsid]), Append(int), Append(short), Append(decimal, int precision, int scale[, bool packed]), Append(byte[]), plus the array variants. The parms.Add(...) extensions mirror every constructor: parms.Add("ABC", 10).AsInput() creates and appends in one call.

After the call, results are read from the output buffer (OutputData):

Accessor Reads
GetString() / GetString(int ccsid) / GetString(int offset, int length) / GetString(int offset, int length, int ccsid) text
GetInt() / GetInt(int offset) BINARY(4), big-endian
GetShort() / GetShort(int offset) BINARY(2), such as a VARCHAR length prefix
GetPackedDecimal(int precision, int scale[, int offset]) packed decimal
GetZonedDecimal(int precision, int scale[, int offset]) zoned decimal
GetBytes() / GetBytes(int offset, int length) raw bytes
GetDTSTimestamp([int offset]) 8 byte *DTS timestamp

InputData and OutputData are public byte[], for full manual control.


Binary data and the hexadecimal contract

NTi never silently converts binary data to text. On a program parameter, read binary segments with GetBytes(offset, length); GetString is meant for text segments, decoded with the job CCSID or an explicit one. On the SQL side the contract is locked down: the data reader's GetString() on a binary column (BINARY, VARBINARY, ROWID or CHAR FOR BIT DATA, CCSID 65535) returns the content as UPPERCASE hexadecimal, without separators (v4 parity). The force translate connection string keyword decodes BINARY/VARBINARY/ROWID as text, but never a FOR BIT DATA column.


Error handling

A failing call (program not found, MCH or CPF escape message) raises a NTiCommandException carrying the server message stack in Messages (Id, Type, Severity, File, Library, Text, SubstitutionData, Help):

try
{
    await conn.CallProgramAsync("MYLIB", "MYPGM", parms);
}
catch (NTiCommandException ex)
{
    foreach (var message in ex.Messages)
        Console.WriteLine($"{message.Id} (severity {message.Severity}): {message.Text}");
}

Two IBM i jobs per connection

An NTi connection opens two jobs on the IBM i: SQL runs in the database server job (QZDASOINIT), while CL commands and program calls run in the command server job (QZRCSRVS). Each job has its own QTEMP and its own CURLIB: an object created in QTEMP by a program is not visible from SQL, and vice versa. To run CL inside the SQL job (same QTEMP, same library list), use CALL QSYS2.QCMDEXC('...'), at the cost of the SQL round trip. The conn.DatabaseJob and conn.CommandJob properties identify both jobs.


Summary

Complete code to call an IBM i program from .NET with NTi:

using System;
using System.Collections.Generic;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

var parms = new List
{
    new NTiProgramParameter("Hello", 10).AsInput(),          // CHAR(10) INPUT
    new NTiProgramParameter("World", 10).AsInput(),          // CHAR(10) INPUT
    new NTiProgramParameter(new byte[] { 0x00 }).AsInput(),  // BYTE(1)  INPUT
    new NTiProgramParameter(128).AsInput(),                  // BYTE(4)  INPUT
    new NTiProgramParameter("", 128).AsOutput(),             // CHAR(128) OUTPUT
    new NTiProgramParameter("", 50)                          // CHAR(50)  INPUT/OUTPUT
};

try
{
    await conn.CallProgramAsync("MYLIB", "MYPGM", parms);

    string message1 = parms[4].GetString(0, 64);
    string message2 = parms[4].GetString(64, 64);
    Console.WriteLine($"{message1} {message2}");
}
catch (NTiCommandException ex)
{
    foreach (var message in ex.Messages)
        Console.WriteLine($"{message.Id} (severity {message.Severity}): {message.Text}");
}

What's next?

Reconnecting to the server...

The connection to the server was lost. The page will reload.