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(orCallProgramsynchronously) - 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=trueto 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?
- Call a service program : call an exported procedure with CallServiceProgram
- Run a CL command : run a CL command and handle errors
- Call a system API : IBM i system API call via a User Space
- NTiProgramParameter : complete parameter class reference