Introduction
Il existe plusieurs façons d’appeler un programme RPG IBM i depuis une application externe. Les procédures stockées SQL, JTOpen via JDBC, un driver ODBC avec wrapper .NET, IWS… Chaque approche a ses cas d’usage légitimes.
Cet article présente l’approche .NET native avec le connecteur NTi Data Provider: un package Nuget, pas d’installation côté IBM i, aucun driver côté client, et un appel de programme RPG en quelque lignes de C# standard. Nous illustrons ici un cas concret et représentatif d’un programme métier:
- Une DataStructure de 219 octets
- Des paramètres de directions mixtes
- Des indicateurs d'erreur par champ retournés sous forme de booléens RPG
Le programme CLIENTS2
CLIENTS2 est un programme RPGLE de gestion client qui encapsule une règle métier et manipule la table CLIENTSP. Il valide les données saisies, génère un numéro client séquentiel, et écrit ou met à jour l’enregistrement dans la base DB2 for i.
Il est présenté ici comme une boîte noire, ce qui compte, c'est la compréhension du contrat d’appel (paramètres, formats, comportements).
Si vous travaillez sur un programme RPG sans documentation, notre article sur l’analyse de programme RPG IBM i avec un agent IA montre comment identifier les paramètres et comprendre la logique en quelques secondes.
Trois choses à retenir sur CLIENTS2:
- Ce qu'il attend : un bloc de
219octets contenant les données du client (SIRET, raison sociale, adresse, contact), plus deux paramètres secondaires non utilisés et un bloc d'indicateurs d'erreur initialisé à zéro. - Ce qu'il fait : vérifier que les champs obligatoires sont renseignés, générer un numéro client séquentiel si c'est une création, écrire ou mettre à jour l'enregistrement dans la table
CLIENTSPen base DB2 for i. - Ce qu'il retourne : un code erreur -
ERou vide - le numéro client généré dans les 10 premiers octets du bloc de données, et 10 indicateurs booléens indiquant précisément quel champ est en erreur.
La signature du programme
CLIENTS2 attend 5 paramètres dans cet ordre exact. C'est la seule chose que NTi a besoin de savoir pour l'appeler.
| Paramètre | Type IBM i | Direction | Rôle |
|---|---|---|---|
pcdaction |
CHAR(2) | Input | Non utilisé, on passe vide |
pcdretour |
CHAR(2) | Output | Non utilisé |
pcderreur |
CHAR(2) | Output | Vide = succès / ER = erreur |
pdsparam |
CHAR(219) | Input/Output | DataStructure client |
pdsindic |
CHAR(10) | Output | 10 indicateurs d’erreur par champ |
Les paramètres 1 et 2 sont déclarés dans le programme RPG mais jamais lus dans la logique.
On leur passe donc des chaînes vides.
La DataStructure pdsparam : 219 octets à positions fixes
En RPG, une DataStructure est un bloc de mémoire continu où chaque champ occupe une position et une taille fixes, des octets à des offsets précis.
Côté .NET, NTi reconstruit ce bloc grâce à la méthode .Append() qui chaîne les champs dans l’ordre, en paddant automatiquement chaque valeur à sa taille exacte avec des blancs EBCDIC.
| Offset | Champ RPG | Taille | Description |
|---|---|---|---|
| 0 | pnumero | 10 | Numéro client (vide = création, renseigné = modification) |
| 10 | psiret | 14 | SIRET |
| 24 | praison | 20 | Raison sociale |
| 44 | pnumvoi | 5 | Numéro de voie |
| 49 | ptypvoi | 5 | Type de voie |
| 54 | pnomvoi | 20 | Nom de voie |
| 74 | pcomple | 20 | Complément d'adresse |
| 94 | plocali | 20 | Localité |
| 114 | pville | 20 | Ville |
| 134 | pcodepo | 5 | Code postal |
| 139 | pnom | 20 | Nom contact |
| 159 | pprenom | 20 | Prénom contact |
| 179 | ptelepf | 10 | Téléphone fixe |
| 189 | ptelepp | 10 | Téléphone portable |
| 199 | pemail | 20 | |
| TOTAL | 219 |
Le champ pnumero pilote le comportement du programme:
- vide = création avec numéro généré par l’IBM i
- renseigné = mise à jour de l’enregistrement existant
Les indicateurs d’erreur pdsindic: 10 booléens RPG
Quand la validation échoue, CLIENTS2 ne retourne pas un message d’erreur générique, mais 10 booléens, un par champ, qui indiquent précisément ce qui manque.
En RPG, un indicateur n est stocké sur 1 octet:
0x00=*off: pas d’erreur sur ce champ0xFF=*on: ce champ est en erreur
| Position | Indicateur RPG | Champ en erreur |
|---|---|---|
| 0 | pin40 | Raison sociale manquante |
| 1 | pin41 | SIRET manquant |
| 2 | pin42 | Numéro de voie manquant |
| 3 | pin43 | Type de voie manquant |
| 4 | pin44 | Nom de voie manquant |
| 5 | pin45 | Code postal manquant |
| 6 | pin46 | Ville manquante |
| 7 | pin47 | Téléphone fixe manquant |
| 8 | pin48 | Téléphone portable manquant |
| 9 | pin49 | Email manquant |
Les positions 7, 8 et 9 fonctionnent ensemble : elles s'activent toutes les trois uniquement si les trois champs de contact sont simultanément vides. Un seul contact renseigné suffit pour les désactiver toutes.
Mise en place du projet
Créer le projet ASP.NET Core
dotnet new webapi -n ClientsApi --use-minimal-apis --framework net8.0
cd ClientsApi
dotnet add package Aumerial.Data.Nti
dotnet add package DapperConfigurer la connexion IBM i dans appsettings.json
{
"ConnectionStrings": {
"NTi": "server=serverName;user=userName;password=pwd"
}
}Deux modèles C#
Models/Client.cs : représente un client tel qu’il est stocké dans la table CLIENTSP. Les noms de propriétés correspondent exactement aux noms de colonnes IBM i, et l’ORM Dapper fait le mapping automatiquement sans alias SQL.
namespace ClientsApi.Models;
public class Client
{
public string? NUMERO { get; set; }
public string? SIRET { get; set; }
public string? RAISON { get; set; }
public string? NOM { get; set; }
public string? PRENOM { get; set; }
public string? NUMVOI { get; set; }
public string? TYPVOI { get; set; }
public string? NOMVOI { get; set; }
public string? COMPLE { get; set; }
public string? LOCALI { get; set; }
public string? VILLE { get; set; }
public string? CODEPO { get; set; }
public string? TELEPF { get; set; }
public string? TELEPP { get; set; }
public string? EMAIL { get; set; }
}
Models/ClientRequest.cs : le corps JSON envoyé pour créer ou modifier un client. Tous les champs sont optionnels, et les champs non fournis sont passés vides au programme RPG.
namespace ClientsApi.Models;
public record ClientRequest(
string? Siret,
string? RaisonSociale,
string? NumeroVoie,
string? TypeVoie,
string? NomVoie,
string? Complement,
string? Localite,
string? Ville,
string? CodePostal,
string? Nom,
string? Prenom,
string? TelephoneFixe,
string? TelephonePortable,
string? Email
);Program.cs : les 4 endpoints
Configuration
using Aumerial.Data.Nti;
using Dapper;
using ClientsApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "Clients IBM i API",
Version = "v1",
Description = "API REST exposant le programme RPG CLIENTS2 sur IBM i via NTi Data Provider"
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var connectionString = builder.Configuration.GetConnectionString("NTi")!;GET /api/clients - lister tous les clients
Lecture directe depuis DB2 for i via Dapper. Aucun appel de programme RPG.
app.MapGet("/api/clients", async () =>
{
using var conn = new NTiConnection(connectionString);
conn.Open();
var clients = await conn.QueryAsync(
"SELECT * FROM ASPAHA.CLIENTSP ORDER BY NUMERO");
return Results.Ok(clients);
})
.WithName("GetClients")
.WithSummary("Liste tous les clients depuis DB2 for i")
.WithOpenApi(); GET /api/clients/{numero} - récupérer un client par son numéro
app.MapGet("/api/clients/{numero}", async (string numero) =>
{
using var conn = new NTiConnection(connectionString);
conn.Open();
var client = await conn.QuerySingleOrDefaultAsync(
"SELECT * FROM ASPAHA.CLIENTSP WHERE NUMERO = ?",
new { numero });
if (client is null)
return Results.NotFound(new { Message = $"Client {numero} introuvable" });
return Results.Ok(client);
})
.WithName("GetClientByNumero")
.WithSummary("Récupère un client par son numéro")
.WithOpenApi(); POST /api/clients - créer un client via CLIENTS2
app.MapPost("/api/clients", (ClientRequest request) =>
{
using var conn = new NTiConnection(connectionString);
conn.Open();
// ASPAHA doit être dans la liste de bibliothèques du job
conn.ExecuteClCommand("ADDLIBLE LIB(ASPAHA)");
var parms = new List
{
new NTiProgramParameter("", 2).AsInput(), // pcdaction, non utilisé
new NTiProgramParameter("", 2).AsOutput(), // pcdretour, non utilisé
new NTiProgramParameter("", 2).AsOutput(), // pcderreur
// pdsparam : DataStructure 219 octets IN/OUT
new NTiProgramParameter("", 10) // pnumero vide = création, le programme génère le numéro en retour
.Append(request.Siret ?? "", 14)
.Append(request.RaisonSociale ?? "", 20)
.Append(request.NumeroVoie ?? "", 5)
.Append(request.TypeVoie ?? "", 5)
.Append(request.NomVoie ?? "", 20)
.Append(request.Complement ?? "", 20)
.Append(request.Localite ?? "", 20)
.Append(request.Ville ?? "", 20)
.Append(request.CodePostal ?? "", 5)
.Append(request.Nom ?? "", 20)
.Append(request.Prenom ?? "", 20)
.Append(request.TelephoneFixe ?? "", 10)
.Append(request.TelephonePortable ?? "", 10)
.Append(request.Email ?? "", 20),
new NTiProgramParameter("", 10).AsOutput() // pdsindic, 10 indicateurs d'erreur
};
conn.CallProgram("ASPAHA", "CLIENTS2", parms);
var erreur = parms[2].GetString(0, 2).Trim();
if (erreur == "ER")
{
// Chaque octet de pdsindic : 0x00 = pas d'erreur, 0xFF = erreur sur ce champ
var bytes = parms[4].GetBytes(0, 10);
var erreurs = new Dictionary();
if (bytes[0] != 0x00) erreurs["raisonSociale"] = "obligatoire";
if (bytes[1] != 0x00) erreurs["siret"] = "obligatoire";
if (bytes[2] != 0x00) erreurs["numeroVoie"] = "obligatoire";
if (bytes[3] != 0x00) erreurs["typeVoie"] = "obligatoire";
if (bytes[4] != 0x00) erreurs["nomVoie"] = "obligatoire";
if (bytes[5] != 0x00) erreurs["codePostal"] = "obligatoire";
if (bytes[6] != 0x00) erreurs["ville"] = "obligatoire";
if (bytes[7] != 0x00 || bytes[8] != 0x00 || bytes[9] != 0x00)
erreurs["contact"] = "téléphone fixe, portable ou email requis";
return Results.UnprocessableEntity(new
{
Statut = "error",
Erreurs = erreurs
});
}
// Le numéro généré par l’IBM i est dans les 10 premiers octets de pdsparam
var numero = parms[3].GetString(0, 10).Trim();
return Results.Created($"/api/clients/{numero}", new
{
Numero = numero,
Statut = "created"
});
})
.WithName("CreateClient")
.WithSummary("Crée un nouveau client via le programme RPG CLIENTS2")
.WithDescription("Le numéro client est généré côté IBM i. La validation est effectuée par le programme RPG.")
.WithOpenApi(); PUT /api/clients/
La seule différence avec le POST : les 10 premiers octets de pdsparam contiennent le numéro client existant, ce qui fait basculer CLIENTS2 en mode modification.
app.MapPut("/api/clients/{numero}", (string numero, ClientRequest request) =>
{
using var conn = new NTiConnection(connectionString);
conn.Open();
// ASPAHA doit être dans la liste de bibliothèques du job
conn.ExecuteClCommand("ADDLIBLE LIB(ASPAHA)");
var parms = new List
{
new NTiProgramParameter("", 2).AsInput(),
new NTiProgramParameter("", 2).AsOutput(),
new NTiProgramParameter("", 2).AsOutput(),
// pnumero renseigné = CLIENTS2 met à jour l'enregistrement existant
new NTiProgramParameter(numero, 10)
.Append(request.Siret ?? "", 14)
.Append(request.RaisonSociale ?? "", 20)
.Append(request.NumeroVoie ?? "", 5)
.Append(request.TypeVoie ?? "", 5)
.Append(request.NomVoie ?? "", 20)
.Append(request.Complement ?? "", 20)
.Append(request.Localite ?? "", 20)
.Append(request.Ville ?? "", 20)
.Append(request.CodePostal ?? "", 5)
.Append(request.Nom ?? "", 20)
.Append(request.Prenom ?? "", 20)
.Append(request.TelephoneFixe ?? "", 10)
.Append(request.TelephonePortable ?? "", 10)
.Append(request.Email ?? "", 20),
new NTiProgramParameter("", 10).AsOutput()
};
conn.CallProgram("ASPAHA", "CLIENTS2", parms);
var erreur = parms[2].GetString(0, 2).Trim();
if (erreur == "ER")
{
var bytes = parms[4].GetBytes(0, 10);
var erreurs = new Dictionary();
if (bytes[0] != 0x00) erreurs["raisonSociale"] = "obligatoire";
if (bytes[1] != 0x00) erreurs["siret"] = "obligatoire";
if (bytes[2] != 0x00) erreurs["numeroVoie"] = "obligatoire";
if (bytes[3] != 0x00) erreurs["typeVoie"] = "obligatoire";
if (bytes[4] != 0x00) erreurs["nomVoie"] = "obligatoire";
if (bytes[5] != 0x00) erreurs["codePostal"] = "obligatoire";
if (bytes[6] != 0x00) erreurs["ville"] = "obligatoire";
if (bytes[7] != 0x00 || bytes[8] != 0x00 || bytes[9] != 0x00)
erreurs["contact"] = "téléphone fixe, portable ou email requis";
return Results.UnprocessableEntity(new
{
Statut = "error",
Erreurs = erreurs
});
}
return Results.Ok(new
{
Numero = numero,
Statut = "updated"
});
})
.WithName("UpdateClient")
.WithSummary("Modifie un client existant via le programme RPG CLIENTS2")
.WithDescription("Le front récupère le client via GET, pré-remplit le formulaire, et envoie l'objet complet modifié.")
.WithOpenApi();
app.Run(); ADDLIBLE: ajouter la bibliothèque au chemin de recherche
CLIENTS2 accède à la table CLIENTSP via la liste de bibliothèque du job *LIBL plutôt que par un nom qualifié (ASPAHA/CLIENTSP)
Dans ce contexte, il est nécessaire d’ajouter explicitement la bibliothèque ASPAHA avant l’appel du programme. Cette approche est adaptée pour du test, mais en production, il est préférable que la bibliothèque soit configurée au niveau du profil utilisateur IBM i ou que les objets soient qualifiés directement dans le programme RPG.
conn.ExecuteClCommand("ADDLIBLE LIB(ASPAHA)");
conn.CallProgram("ASPAHA", "CLIENTS2", parms);Tester avec Swagger
Swagger est activé par défaut en mode développement.
Lancez le projet avec dotnet run et ouvrez https://localhost:PORT/swagger/index.html.
Simuler une erreur de validation
POST /api/clients avec telephoneFixe, telephonePortable et email vides. Tous les autres champs sont renseignés.

Réponse 422 avec les erreurs de champs qui viennent directement des indicateurs RPG pdsindic retournés par CLIENTS2. La logique métier reste côté IBM i.

Côté IBM i, CLIENTS2 confirme l’erreur.
Créer un client
POST /api/clients avec tous les champs obligatoires renseignés.

Réponse 201 Created, 0000000007 a été généré par CLIENTS2 côté IBM i. L'API ne le connaissait pas avant l'appel.

Côté IBM i, CLIENTS2 confirme par création réussie.
Modifier un client
GET /api/clients/ pour récupérer le client existant, modifier le champ souhaité, puis PUT /api/clients/ avec l'objet complet modifié.

Réponse 200 OK, le client 0000000007 a été mis à jour.

Côté IBM i, CLIENTS2 confirme par modification réussie.
Conclusion
Exposer un programme RPG en API REST, c'est finalement peu de code. Dans notre exemple, deux modèles, quatre endpoints, un package NuGet. Le plus long ici a été de comprendre la signature du programme et de construire la DataStructure correctement, pas d'écrire le C#.
Ce qui rend cette approche crédible sur le long terme, c'est que NTi repose entièrement sur .NET : un écosystème actif, multiplateforme, qui tourne aussi bien sur Windows que sur Linux, dans Docker, sur Power...
La stack reste dans l'air du temps. L'IBM i aussi.