tutoriels

Exposer un programme RPGLE IBM i existant en API REST .NET

ParQuentin DESTRADE

image d’illustration de l’article

Contenu détaillé de l’article:Exposer un programme RPGLE IBM i existant en API REST .NET

Un programme RPGLE IBM i, une signature connue, et quelques lignes de C#. Cet article montre comment exposer une règle métier existante en API REST .NET

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 219 octets 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 CLIENTSP en base DB2 for i.
  • Ce qu'il retourne : un code erreur - ER ou 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 Email
  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 champ
  • 0xFF = *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 Dapper

Configurer 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 Unprocessable Entity retournée par l'API REST lors d'une erreur de validation CLIENTS2

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.

Message IBM i confirmant l'erreur de validation lors de l'appel du programme RPGLE CLIENTS2

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 avec le numéro client généré par CLIENTS2 côté IBM i

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.

Message IBM i confirmant la création réussie du client par le programme RPGLE CLIENTS2

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é.

Message IBM i confirmant la création réussie du client par le programme RPGLE CLIENTS2

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

Message IBM i confirmant la modification réussie du client par le programme RPGLE CLIENTS2

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.

Démarrez dès maintenant

Récupérez votre licence d’essai gratuite en ligne
et connectez vos applications .NET à votre IBM i en quelques minutes.

Créez votre compte

Connectez-vous au portail Aumerial, générez votre licence d’essai et activez NTi sur votre IBM i en quelques instants.

Démarrer l’essai

Ajouter NTi à votre projet

Installez NTi Data Provider depuis NuGet dans Visual Studio et référencez-le dans votre projet .NET.

Voir la documentation

Besoin d’aide ?

Si vous avez des questions sur nos outils ou sur les options de licence, notre équipe est disponible pour vous aider.

Nous contacter
30 jours d’essai gratuit activation immédiate sans engagement aucun composant à installer côté IBM i