Aller au contenu
Node.js 24 · Fastify · Db2 for i · RPG · XMLSERVICE · HTTPS · Postman

Node.js sur IBM i, des webservices REST devant Db2 et le RPG

Ce qu'est Node.js, qui l'a porté sur IBM i et à quoi il sert. Puis l'installation par yum, une table Db2 for i et trois webservices : un CRUD, l'appel d'un programme RPG existant et une transaction SQL. Avec un écran de test, des tests automatiques dans Chrome et Postman, et toutes les erreurs rencontrées en route.

24version de Node.js installée (24.22.1)
3webservices REST : Db2, RPG, transaction
118tests et assertions passés, 0 échec
15erreurs rencontrées, avec leur correctif

En une phrase

Node.js fait tourner du JavaScript côté serveur ; sur IBM i, il tourne dans PASE et parle directement à Db2 for i et aux programmes RPG : c'est une façon simple de mettre une API REST ou un écran web devant une application existante, sans la réécrire.

Node.js, vite fait

Node.js a été écrit par Ryan Dahl en 2009 et présenté à la première JSConf européenne, à Berlin. L'idée : prendre V8, le moteur JavaScript de Chrome, et lui donner accès aux fichiers et au réseau. Le gestionnaire de paquets npm est arrivé en 2010 ; le projet est aujourd'hui porté par l'OpenJS Foundation.

Sa particularité est la boucle d'événements : un seul fil d'exécution, qui ne reste jamais à attendre. Pendant que Db2 répond à une requête, Node.js traite les autres. Pour un webservice, qui passe l'essentiel de son temps à attendre la base, c'est un modèle très économe.

Les versions paires deviennent des versions à support long (LTS) : ce sont celles à mettre en production. Les versions impaires sont des versions de passage.

Qui l'a mis sur IBM i ?

IBM, le 15 décembre 2014 : Node.js arrive comme option 1 du programme sous licence 5733-OPS (IBM i Open Source Solutions), annoncé avec le TR9 de la 7.1 et le TR1 de la 7.2. Il tourne dans PASE (5770-SS1 option 33), l'environnement d'IBM i qui exécute les programmes AIX.

Le 5733-OPS a ensuite laissé la place aux paquets RPM installés par yum, comme Python, Git ou Mapepire. En 2018, IBM a indiqué que toutes les modifications propres à IBM i étaient dans le dépôt officiel de Node.js depuis la version 10 : IBM i est une plateforme de Node.js comme une autre, et process.platform y vaut os400.

IBM maintient aussi les paquets npm qui font le lien avec le système : idb-connector et idb-pconnector pour Db2 en local, itoolkit pour appeler les programmes RPG, CL ou les commandes par XMLSERVICE, et le paquet odbc pour Db2 à distance.

À quoi ça sert sur IBM i

À ouvrir l'application IBM i sur l'extérieur sans toucher à ce qui marche : les données restent dans Db2, les règles dans le RPG, et Node.js fait la traduction en HTTP et en JSON.

BesoinOutilSur cette page
Exposer une table Db2 en RESTidb-pconnector (ou odbc)Exemple 1 : CRUD sur NODEWS.ARTICLE
Réutiliser un programme RPG ou CL existantitoolkit + XMLSERVICEExemple 2 : le RPG CALCDEVIS devient POST /api/devis
Garder la règle métier dans Db2idb-pconnector, CALL d'une procédure SQLExemple 3 : MVT_STOCK, tout ou rien
Un écran web pour les utilisateursFichiers statiques servis par Node.jsL'écran de test, au design Carbon d'IBM
Appeler des API du monde extérieurfetch, intégré à Node.jsMétéo, paiement, transporteur, IA
Des outils d'exploitation en scriptnode, npm, les services SQL QSYS2Ce que l'on faisait en CL ou en QSH

Face au webservice en RPG (CGI ou IWS), Node.js apporte un écosystème immense (npm), la validation des entrées, la documentation OpenAPI et les tests automatiques presque sans effort. Le RPG garde l'avantage quand toute la logique est déjà en RPG et qu'on veut un seul langage.

Le cheminement d'une requête

De Postman au programme RPG Le navigateur ou Postman envoie une requête HTTPS sur le port 3443 à l'IBM HTTP Server (Apache), qui déchiffre et la relaie à Node.js sur 127.0.0.1:3030. Node.js tourne dans PASE, dans un travail batch NODEWS sous le profil NODEAPP. Fastify valide la requête, puis Node.js appelle Db2 for i par idb-pconnector, ou le programme RPG CALCDEVIS par itoolkit et XMLSERVICE, ou la procédure SQL MVT_STOCK. Navigateur, PostmanHTTPS + JSON, port 3443 TLS 1.3 IBM HTTP Server (Apache)reverse proxy, instance NODEWS HTTP local, 127.0.0.1:3030 Node.js 24 + Fastify (PASE)travail NODEWS, profil NODEAPP idb-pconnectorSQL, exemple 1 itoolkitXMLSERVICE, ex. 2 CALL SQLMVT_STOCK, ex. 3 Table ARTICLE RPG CALCDEVIS ARTICLE + MOUVEMENT
Tout se passe sur l'IBM i : Apache, Node.js, Db2 et le RPG sont dans la même partition. Seul le port HTTPS est ouvert vers l'extérieur.
ÉtapeQuiQuoi (exemple 2)
1Le navigateur ou Postman envoiePOST /api/devis avec {"code":"LIC-RDI","quantite":60}
2Fastify valideLe schéma JSON refuse tout corps incorrect par un 400, avant d'appeler l'IBM i
3Node.js appelleitoolkit écrit le XML d'appel et le passe à XMLSERVICE, dans le même travail
4Le RPG calculeCALCDEVIS lit l'article en SQL et rend PU, remise, HT, TVA, TTC, code retour
5Node.js répondCode retour RPG traduit en statut HTTP (OK = 200, INC = 404), puis JSON

Étape 1 Installer

Installer le dernier Node.js par yum

Prérequis : l'environnement open source d'IBM i (yum) et un accès au dépôt d'IBM. Depuis une session SSH, ou ACS (Open Source Package Management), on regarde d'abord ce que propose le dépôt :

$ yum list available 'nodejs*'
nodejs14.ppc64     14.21.3-1     ibmi-base
nodejs16.ppc64     16.20.2-1     ibmi-base
nodejs18.ppc64     18.20.8-1     ibmi-base
nodejs20.ppc64     20.20.1-1     ibmi-base
nodejs22.ppc64     22.23.4-1     ibmi-base
nodejs24.ppc64     24.22.1-1     ibmi-base

La plus récente est nodejs24. Le paquet nodever permet ensuite de changer de version par défaut si plusieurs sont installées :

$ yum -y install nodejs24 nodever
Setting up Install Process
Resolving Dependencies
--> Running transaction check
---> Package nodejs24.ppc64 0:24.22.1-1 will be installed
---> Package nodever.noarch 0:1.0.0-4 will be installed
--> Finished Dependency Resolution
Dependencies Resolved
================================================================================
 Package           Arch            Version             Repository          Size
================================================================================
Installing:
 nodejs24          ppc64           24.22.1-1           ibmi-base           34 M
 nodever           noarch          1.0.0-4             ibmi-base          9.7 k
Transaction Summary
================================================================================
Install       2 Packages
Total download size: 34 M
Installed size: 209 M
Downloading Packages:
--------------------------------------------------------------------------------
Total                                           321 kB/s |  34 MB     01:48
Running Transaction Check
Running Transaction Test
Transaction Test Succeeded
Running Transaction
  Installing : nodever-1.0.0-4.noarch                                       1/2
  Installing : nodejs24-24.22.1-1.ppc64                                     2/2
update-alternatives: using /QOpenSys/pkgs/lib/nodejs24/bin/node to provide /QOpenSys/pkgs/bin/node (node) in auto mode
Installed:
  nodejs24.ppc64 0:24.22.1-1              nodever.noarch 0:1.0.0-4
Complete!
$ node -v ; npm -v ; nodever list ; node -p 'process.platform + " " + process.arch'
v24.22.1
11.19.1
Running command: alternatives --config node
Consider calling update-alternatives directly next time.
There is only one alternative in link group node (providing /QOpenSys/pkgs/bin/node): /QOpenSys/pkgs/lib/nodejs24/bin/node
Nothing to configure.
os400 ppc64

34 Mo téléchargés, 209 Mo installés dans /QOpenSys/pkgs/lib/nodejs24. Rien à faire côté IBM i : pas de PTF, pas d'IPL. Le profil qui installe doit avoir le droit spécial *ALLOBJ : yum écrit dans /QOpenSys/pkgs, qui appartient à QSYS.

Installer depuis une session 5250 (QSH), sans SSH

Sur un IBM i 7.4 de production, le serveur SSH n'était pas démarré : l'installation s'est faite depuis un simple écran 5250, dans QSH (STRQSH). Deux surprises en chemin, toutes deux sans gravité une fois comprises.

1. « yum » introuvable. QSH ne cherche pas dans /QOpenSys/pkgs/bin, où yum installe ses commandes. Le message ne prouve donc pas que l'open source est absent : on vérifie la présence des fichiers, puis on complète le PATH.

> yum
  qsh: 001-0019 Error found searching for command yum. No such path or directory.
> ls -l /QOpenSys/pkgs/bin/yum /QOpenSys/pkgs/bin/rpm
  -rwxr-xr-x  1 QSYS  0   149699 Dec  3  2024 /QOpenSys/pkgs/bin/rpm
  -rwxr-xr-x  1 QSYS  0      822 Jul 20  2022 /QOpenSys/pkgs/bin/yum
> export PATH=/QOpenSys/pkgs/bin:$PATH

Si ces deux fichiers n'existent pas, l'open source n'a jamais été installé : il faut d'abord l'amorcer (bootstrap) avec ACS, Outils, Gestion des paquets open source, ce qui demande le serveur SSH (STRTCPSVR SERVER(*SSHD)).

2. « getaddrinfo() thread failed to start ». La liste des versions s'affiche (elle vient du cache de yum), mais avec une erreur par dépôt, et l'installation échoue faute de pouvoir télécharger quoi que ce soit :

> yum list available 'nodejs*'
  https://public.dhe.ibm.com/software/ibmi/products/pase/rpms/repo-base-7.3/repodata/repomd.xml:
  [Errno 14] curl#6 - "getaddrinfo() thread failed to start"
  Essai d'un autre miroir.
> yum -y install nodejs24 nodever
  Erreur durant le téléchargement des paquets :
    libuuid1-2.40.4-1.ppc64: failure: ... from ibmi-base: [Errno 256] No more mirrors to try.
    python39-3.9.21-2.ppc64: failure: ... from ibmi-base: [Errno 256] No more mirrors to try.
    nodejs24-24.22.1-1.ppc64: failure: ... from ibmi-base: [Errno 256] No more mirrors to try.
    nodever-1.0.0-4.noarch: failure: ... from ibmi-base: [Errno 256] No more mirrors to try.

Ce n'est pas le réseau : curl résout le nom du serveur d'IBM dans un thread, et un travail QSH n'accepte pas plusieurs threads par défaut. On quitte QSH par F3 (pas F12, qui laisse la session ouverte), on autorise les threads, et on relance :

ADDENVVAR ENVVAR(QIBM_MULTI_THREADED) VALUE(Y) REPLACE(*YES)
STRQSH
> export PATH=/QOpenSys/pkgs/bin:$PATH
> yum -y install nodejs24 nodever
  ibmi-base                                    | 3.6 kB  00:00
  ibmi-base/primary_db    33% [=====-      ]  18 kB/s | 160 kB  00:18 ETA

Le -y est utile : QSH gère mal la question « Is this ok [y/N] ». yum tire au passage ses dépendances (python39, libuuid1). Le débit peut être faible derrière le pare-feu d'une entreprise : ne pas interrompre yum (ni F3 ni Appel système) avant le message final. Autre solution : passer par CALL QP2TERM ou par SSH, qui n'ont pas cette limite.

Le dépôt repo-base-7.3 qui apparaît dans les messages n'est pas un reste d'une ancienne version : c'est le dépôt de base d'IBM pour la 7.3 et les versions suivantes. Ses paquets s'appellent *.ibmi7.3.ppc64.rpm, y compris sur un système en 7.4 ou 7.5.

Un profil de service, pas son propre profil

L'application ne doit pas tourner sous un profil *SECOFR. On crée un profil sans mot de passe ni droit spécial, qui recevra juste ce qu'il faut :

CRTUSRPRF USRPRF(NODEAPP) PASSWORD(*NONE) USRCLS(*USER) SPCAUT(*NONE)
          INLMNU(*SIGNOFF) LMTCPB(*YES) HOMEDIR('/www/nodews')
          TEXT('Service Node.js webservices NODEWS')
GRTOBJAUT OBJ(QSYS/NODEWS)      OBJTYPE(*LIB)  USER(NODEAPP) AUT(*USE)
GRTOBJAUT OBJ(NODEWS/ARTICLE)   OBJTYPE(*FILE) USER(NODEAPP) AUT(*CHANGE)
GRTOBJAUT OBJ(NODEWS/MOUVEMENT) OBJTYPE(*FILE) USER(NODEAPP) AUT(*CHANGE)
GRTOBJAUT OBJ(NODEWS/CALCDEVIS) OBJTYPE(*PGM)  USER(NODEAPP) AUT(*USE)
GRTOBJAUT OBJ(NODEWS/MVTSTOCK)  OBJTYPE(*PGM)  USER(NODEAPP) AUT(*USE)

Le projet et ses paquets

Le code est copié dans /www/nodews, puis npm ci --omit=dev installe exactement les versions du package-lock.json. Les pilotes IBM i sont en dépendances facultatives : le même projet s'installe aussi sur un PC pour les tests unitaires.

{
  "name": "nodews-ibmi",
  "version": "1.0.0",
  "description": "Trois webservices REST Node.js sur IBM i : CRUD Db2 for i, appel d'un programme RPG, transaction SQL",
  "private": true,
  "type": "module",
  "main": "src/server.js",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "start": "node --env-file=.env src/server.js",
    "test": "node --test \"test/unit/**/*.test.js\"",
    "test:integ": "node --env-file=.env --test --test-concurrency=1 \"test/integration/**/*.test.js\"",
    "test:e2e": "node --test \"test/e2e/**/*.test.js\""
  },
  "dependencies": {
    "@fastify/helmet": "^13.2.0",
    "@fastify/static": "^10.1.8",
    "@fastify/swagger": "^9.9.2",
    "@fastify/swagger-ui": "^6.1.2",
    "fast-xml-parser": "^5.7.0",
    "fastify": "^5.12.7",
    "itoolkit": "^1.1.0"
  },
  "optionalDependencies": {
    "idb-connector": "^1.3.1",
    "idb-pconnector": "^1.2.0"
  },
  "devDependencies": {
    "playwright-core": "^1.64.1"
  },
  "license": "MIT"
}

Le lancer comme un service

Service Commander (paquet service-commander, déjà là si Mapepire est installé) soumet Node.js en batch, sous NODEAPP, et surveille le port :

# Service Commander : sc start nodews | sc stop nodews | sc check nodews
# Copié dans /QOpenSys/etc/sc/services/ (déclaration pour tout le système).
# Le travail est soumis en batch sous le profil de service NODEAPP
# (ni *ALLOBJ, ni mot de passe), dans la file non limitée QUSRNOMAX.
name: Webservices Node.js NODEWS
dir: /www/nodews
start_cmd: node --env-file=/www/nodews/.env src/server.js
check_alive: '3030'
batch_mode: 'true'
sbmjob_jobname: NODEWS
sbmjob_opts: 'JOBQ(QUSRNOMAX) USER(NODEAPP)'
environment_vars:
- PATH=/QOpenSys/pkgs/bin:/QOpenSys/usr/bin:/usr/bin
- NODE_ENV=production
$ cp /www/nodews/service/nodews.yaml /QOpenSys/etc/sc/services/
$ sc start nodews
Performing operation 'START' on service 'nodews'
Service 'Webservices Node.js NODEWS' successfully started
$ sc check nodews
  RUNNING            | nodews (Webservices Node.js NODEWS)
JOB_NAME              | AUTHORIZATION_NAME | JOB_STATUS | SUBSYSTEM
----------------------+--------------------+------------+----------
151924/NODEAPP/NODEWS | NODEAPP            | SELW       | QUSRWRK

La clé API est rangée dans un fichier .env lisible par NODEAPP seul (droits 600), lu par node --env-file : aucun secret dans le code.

Mettre du HTTPS devant Node.js

En HTTP, la clé API circulerait en clair. Plutôt que de gérer un certificat dans Node.js, on place devant lui une instance de l'IBM HTTP Server (Apache) en reverse proxy : Apache chiffre sur le port 3443 avec un certificat géré par DCM, et relaie en local à Node.js, qui n'écoute plus que sur 127.0.0.1 (HOST=127.0.0.1 dans le .env). Le port 3030 n'est plus joignable de l'extérieur.

# NODEWS - Instance IBM HTTP Server (Apache) en reverse proxy HTTPS devant Node.js
# Apache termine le TLS sur *:3443 et relaie vers Node.js, qui n'écoute plus qu'en
# local sur 127.0.0.1:3030 (HOST=127.0.0.1 dans /www/nodews/.env).
# Certificat : ID d'application DCM QIBM_HTTP_SERVER_FORMATIONS (partagé avec
# FORMATIONS et WSCLI), CN=nom DNS de la machine, émis par l'autorité interne Armonie.
LoadModule ibm_ssl_module /QSYS.LIB/QHTTPSVR.LIB/QZSRVSSL.SRVPGM
LoadModule proxy_module /QSYS.LIB/QHTTPSVR.LIB/QZSRCORE.SRVPGM
LoadModule proxy_http_module /QSYS.LIB/QHTTPSVR.LIB/QZSRCORE.SRVPGM
DefaultNetCCSID 1208
Listen *:3443
ServerName mon-ibmi.exemple.fr
TraceEnable Off
ServerSignature Off
Options -FollowSymLinks
LogFormat "%h %T %l %u %t \"%r\" %>s %b" combined
CustomLog logs/access_log combined
ErrorLog logs/error_log
LogMaint logs/access_log 7 0
LogMaint logs/error_log 7 0
LogLevel warn
<Directory />
   Require all denied
</Directory>
# Reverse proxy uniquement (jamais de proxy ouvert)
ProxyRequests Off
ProxyPreserveHost On
ProxyTimeout 60
<VirtualHost *:3443>
   SSLEngine On
   SSLAppName QIBM_HTTP_SERVER_FORMATIONS
   SSLProtocolDisable SSLv3 TLSv1 TLSv1.1
   ProxyPass / http://127.0.0.1:3030/
   ProxyPassReverse / http://127.0.0.1:3030/
</VirtualHost>
HTTPSubsystemDesc QHTTPSVR/QHTTPSVR
HTTPStartJobDesc QHTTPSVR/QZHBHTTP
HTTPStartJobQueue *JOBD
HTTPRoutingData *JOBD

L'instance se déclare comme toutes les instances HTTP, par un membre de QUSRSYS/QATMHINSTC (un fichier à une seule colonne, rempli par SQL au travers d'un alias), puis se démarre :

ADDPFM FILE(QUSRSYS/QATMHINSTC) MBR(NODEWS) TEXT('Reverse proxy HTTPS Node.js NODEWS')
CREATE OR REPLACE ALIAS NODEWS.A_INST FOR QUSRSYS.QATMHINSTC(NODEWS);
INSERT INTO NODEWS.A_INST VALUES('-apache -d /www/nodews/apache -f conf/httpd.conf');
DROP ALIAS NODEWS.A_INST;
STRTCPSVR SERVER(*HTTP) HTTPSVR(NODEWS)
TCP1A0F  HTTP server starting.
LOCAL_ADDRESS | LOCAL_PORT | TCP_STATE
--------------+------------+----------
0.0.0.0       | 3443       | LISTEN
127.0.0.1     | 3030       | LISTEN

$ openssl s_client -connect mon-ibmi.exemple.fr:3443
Protocol: TLSv1.3   Cipher is TLS_AES_128_GCM_SHA256
(TLS 1.0 et TLS 1.1 refusés)

Le répertoire logs de l'instance doit appartenir à QTMHHTTP, le profil des travaux Apache. Le certificat venant d'une autorité interne, les clients doivent la connaître : Chrome et Postman la lisent dans le magasin Windows ; newman, qui tourne sous Node.js, a besoin de NODE_OPTIONS=--use-system-ca.

Étape 2 La base

Les tables Db2 for i

Un schéma SQL (donc journalisé, ce qu'exige l'exemple 3), une table d'articles et une table de mouvements, avec des contraintes : c'est Db2 qui garantit un code unique, un prix positif, un taux de TVA connu et un stock jamais négatif.

-- =====================================================================
-- NODEWS : schéma de démonstration des webservices Node.js sur IBM i
-- Cible : un IBM i 7.5, à exécuter une fois (ACS Run SQL Scripts
-- ou ibmi.py sql). CREATE SCHEMA crée aussi le journal QSQJRN : toutes
-- les tables sont journalisées, donc utilisables sous contrôle de
-- validation (indispensable pour l'exemple 3 transactionnel).
-- =====================================================================

CREATE SCHEMA NODEWS;

-- LABEL ON SCHEMA n'existe pas en Db2 for i (SQL0104) : le texte de la
-- bibliothèque se pose par CHGOBJD, appelé depuis SQL.
CALL QSYS2.QCMDEXC('CHGOBJD OBJ(QSYS/NODEWS) OBJTYPE(*LIB) TEXT(''Demo webservices Node.js'')');

-- Les noms de contraintes non qualifiés prennent le schéma par défaut du
-- job (le profil, ici SAK) : sans SET SCHEMA, SQL5051 sur ARTICLE_PK.
SET SCHEMA NODEWS;

-- ---------------------------------------------------------------------
-- Catalogue d'articles (exemple 1 : CRUD REST)
-- Noms SQL longs pour Node.js, noms système courts pour le 5250 (DSPFFD,
-- RPG en colonnes, Query/400).
-- ---------------------------------------------------------------------
-- Pas de FOR SYSTEM NAME : ARTICLE est déjà un nom système valide (SQL7029)
CREATE TABLE NODEWS.ARTICLE (
  ID            INTEGER       GENERATED ALWAYS AS IDENTITY
                              (START WITH 1 INCREMENT BY 1),
  CODE          VARCHAR(15)   NOT NULL,
  LIBELLE       VARCHAR(80)   NOT NULL,
  FAMILLE       VARCHAR(30)   NOT NULL DEFAULT 'DIVERS',
  PRIX_HT       FOR COLUMN PRIXHT   DECIMAL(11, 2) NOT NULL DEFAULT 0,
  TAUX_TVA      FOR COLUMN TAUXTVA  DECIMAL(5, 2)  NOT NULL DEFAULT 20,
  STOCK         INTEGER       NOT NULL DEFAULT 0,
  DATE_CREATION FOR COLUMN DATCRE   TIMESTAMP NOT NULL
                              DEFAULT CURRENT TIMESTAMP,
  DATE_MAJ      FOR COLUMN DATMAJ   TIMESTAMP NOT NULL
                              GENERATED ALWAYS FOR EACH ROW ON UPDATE
                              AS ROW CHANGE TIMESTAMP,
  CONSTRAINT ARTICLE_PK      PRIMARY KEY (ID),
  CONSTRAINT ARTICLE_CODE_UK UNIQUE (CODE),
  CONSTRAINT ARTICLE_PRIX_CK CHECK (PRIX_HT >= 0),
  CONSTRAINT ARTICLE_TVA_CK  CHECK (TAUX_TVA IN (0, 2.1, 5.5, 10, 20)),
  CONSTRAINT ARTICLE_STK_CK  CHECK (STOCK >= 0)
) RCDFMT ARTICLER;

LABEL ON TABLE NODEWS.ARTICLE IS 'Catalogue articles (demo Node.js)';
LABEL ON COLUMN NODEWS.ARTICLE (
  ID            TEXT IS 'Identifiant',
  CODE          TEXT IS 'Code article',
  LIBELLE       TEXT IS 'Libelle',
  FAMILLE       TEXT IS 'Famille',
  PRIX_HT       TEXT IS 'Prix unitaire HT',
  TAUX_TVA      TEXT IS 'Taux de TVA',
  STOCK         TEXT IS 'Quantite en stock',
  DATE_CREATION TEXT IS 'Date de creation',
  DATE_MAJ      TEXT IS 'Date de derniere modification'
);

-- Index pour la recherche par famille (filtre ?famille= de l'API)
CREATE INDEX NODEWS.ARTICLE_FAMILLE_IX FOR SYSTEM NAME ARTICLE1
  ON NODEWS.ARTICLE (FAMILLE, CODE);

-- ---------------------------------------------------------------------
-- Mouvements de stock (exemple 3 : transaction)
-- ---------------------------------------------------------------------
CREATE TABLE NODEWS.MOUVEMENT (
  ID          INTEGER      GENERATED ALWAYS AS IDENTITY,
  ARTICLE_ID  FOR COLUMN ARTID  INTEGER  NOT NULL,
  TYPE_MVT    FOR COLUMN TYPMVT CHAR(1)  NOT NULL,
  QUANTITE    FOR COLUMN QTE    INTEGER  NOT NULL,
  STOCK_APRES FOR COLUMN STKAPR INTEGER  NOT NULL,
  COMMENTAIRE FOR COLUMN COMMEN VARCHAR(100) NOT NULL DEFAULT '',
  UTILISATEUR FOR COLUMN UTIL   VARCHAR(18)  NOT NULL DEFAULT USER,
  DATE_MVT    FOR COLUMN DATMVT TIMESTAMP    NOT NULL
                            DEFAULT CURRENT TIMESTAMP,
  CONSTRAINT MOUVEMENT_PK      PRIMARY KEY (ID),
  CONSTRAINT MOUVEMENT_ART_FK  FOREIGN KEY (ARTICLE_ID)
                               REFERENCES NODEWS.ARTICLE (ID)
                               ON DELETE RESTRICT,
  CONSTRAINT MOUVEMENT_TYPE_CK CHECK (TYPE_MVT IN ('E', 'S')),
  CONSTRAINT MOUVEMENT_QTE_CK  CHECK (QUANTITE > 0)
) RCDFMT MOUVEMENTR;

LABEL ON TABLE NODEWS.MOUVEMENT IS 'Mouvements de stock (demo Node.js)';

CREATE INDEX NODEWS.MOUVEMENT_ARTICLE_IX FOR SYSTEM NAME MOUVEMENT1
  ON NODEWS.MOUVEMENT (ARTICLE_ID, DATE_MVT DESC);

Exemple 1 CRUD

Un CRUD REST sur une table Db2

Cinq routes : GET /api/articles (liste paginée, filtres), GET, POST, PUT et DELETE /api/articles/{id}. L'accès à Db2 tient dans un petit module autour du pool de connexions d'idb-pconnector :

// Accès Db2 for i par idb-pconnector : pilote natif PASE, la connexion
// s'ouvre dans le travail du serveur Node.js (*LOCAL), sans mot de passe,
// avec les droits du profil qui exécute le travail.

const SQLSTATE = /SQLSTATE=(\w{5})/;

/** Extrait le SQLSTATE et le texte Db2 d'une erreur idb (enfouis dans la pile). */
export function sqlInfo(error) {
  const texte = `${error?.message ?? ''}\n${error?.stack ?? ''}`;
  const etat = texte.match(SQLSTATE)?.[1] ?? null;
  const ligne = texte.split('\n').find((l) => SQLSTATE.test(l)) ?? '';
  return { sqlState: etat, detail: ligne.trim() };
}

export async function createDb({ poolSize = 4 } = {}) {
  // Import dynamique : le module n'existe que sur IBM i, les tests unitaires
  // du poste n'en ont pas besoin.
  const { default: idb } = await import('idb-pconnector');
  const pool = new idb.DBPool(
    { url: '*LOCAL' },
    // Sans enableNumericTypeConversion, les DECIMAL arrivent en chaînes
    { incrementSize: poolSize, enableNumericTypeConversion: true },
  );

  const executer = async (sql, params, options) => {
    try {
      return await pool.prepareExecute(sql, params, options);
    } catch (error) {
      Object.assign(error, sqlInfo(error));
      throw error;
    }
  };

  return {
    /** SELECT : renvoie toujours un tableau de lignes. */
    async query(sql, params = []) {
      const r = await executer(sql, params, { io: 'in' });
      return r?.resultSet ?? [];
    },
    /** INSERT, UPDATE, DELETE sans résultat. */
    async execute(sql, params = []) {
      await executer(sql, params, { io: 'in' });
    },
    /** CALL avec paramètres OUT : renvoie les valeurs de sortie dans l'ordre. */
    async call(sql, params = []) {
      const r = await executer(sql, params, {});
      return r?.outputParams ?? [];
    },
    async close() {
      await pool.retireAll();
    },
  };
}

Le dépôt d'articles n'écrit que des requêtes à marqueurs ? : aucune saisie n'est collée dans le SQL. FINAL TABLE rend l'identifiant créé sans second aller-retour.

// Exemple 1 : accès à NODEWS.ARTICLE. Toujours des marqueurs « ? » :
// jamais de valeur saisie concaténée dans le SQL (injection impossible).

const COLONNES = `
  ID            AS "id",
  CODE          AS "code",
  LIBELLE       AS "libelle",
  FAMILLE       AS "famille",
  PRIX_HT       AS "prixHt",
  TAUX_TVA      AS "tauxTva",
  STOCK         AS "stock",
  VARCHAR_FORMAT(DATE_CREATION, 'YYYY-MM-DD HH24:MI:SS') AS "dateCreation",
  VARCHAR_FORMAT(DATE_MAJ,      'YYYY-MM-DD HH24:MI:SS') AS "dateMaj"`;

export function createArticleRepository(db, schema) {
  const table = `${schema}.ARTICLE`;

  return {
    async list({ famille, q, limit = 50, offset = 0 } = {}) {
      const where = [];
      const params = [];
      if (famille) {
        where.push('FAMILLE = ?');
        params.push(famille.toUpperCase());
      }
      if (q) {
        where.push('(UPPER(LIBELLE) LIKE ? OR CODE LIKE ?)');
        const motif = `%${q.toUpperCase()}%`;
        params.push(motif, motif);
      }
      const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';

      const [{ total }] = await db.query(
        `SELECT COUNT(*) AS "total" FROM ${table} ${clause}`, params);
      const items = await db.query(
        `SELECT ${COLONNES} FROM ${table} ${clause}
          ORDER BY CODE
         OFFSET ? ROWS FETCH FIRST ? ROWS ONLY`,
        [...params, offset, limit]);
      return { total, limit, offset, items };
    },

    async get(id) {
      const [ligne] = await db.query(
        `SELECT ${COLONNES} FROM ${table} WHERE ID = ?`, [id]);
      return ligne ?? null;
    },

    async create(a) {
      // FINAL TABLE renvoie l'identité générée sans second aller-retour
      const [{ id }] = await db.query(
        `SELECT ID AS "id" FROM FINAL TABLE (
           INSERT INTO ${table} (CODE, LIBELLE, FAMILLE, PRIX_HT, TAUX_TVA, STOCK)
           VALUES (?, ?, ?, ?, ?, ?))`,
        [a.code.toUpperCase(), a.libelle, (a.famille ?? 'DIVERS').toUpperCase(),
          a.prixHt, a.tauxTva ?? 20, a.stock ?? 0]);
      return this.get(id);
    },

    async update(id, a) {
      if (!(await this.get(id))) return null;
      await db.execute(
        `UPDATE ${table}
            SET CODE = ?, LIBELLE = ?, FAMILLE = ?, PRIX_HT = ?, TAUX_TVA = ?
          WHERE ID = ?`,
        [a.code.toUpperCase(), a.libelle, (a.famille ?? 'DIVERS').toUpperCase(),
          a.prixHt, a.tauxTva ?? 20, id]);
      return this.get(id);
    },

    async remove(id) {
      if (!(await this.get(id))) return false;
      await db.execute(`DELETE FROM ${table} WHERE ID = ?`, [id]);
      return true;
    },

    async familles() {
      return db.query(
        `SELECT FAMILLE AS "famille", COUNT(*) AS "articles",
                SUM(STOCK) AS "stock", SUM(STOCK * PRIX_HT) AS "valeurHt"
           FROM ${table} GROUP BY FAMILLE ORDER BY FAMILLE`);
    },
  };
}
Écran de test au design Carbon : en-tête noir IBM i Webservices Node.js, onglets Articles, Devis, Mouvements, Supervision, tableau des 10 articles avec prix, TVA et stock
L'écran de test, au design Carbon d'IBM, lit GET /api/articles. Le journal des appels, en bas, montre chaque requête.
Notification jaune Erreur 409 Ce code article existe déjà, au-dessus du formulaire de création
Un code en double : Db2 refuse (SQLSTATE 23505), Node.js le traduit en 409 avec un message clair.

Exemple 2 Programme RPG

Un programme RPG existant devient un webservice

CALCDEVIS est un programme RPG full free ordinaire : un code article et une quantité en entrée, le devis en sortie. Il ne sait rien de HTTP ni de JSON, et n'a pas à être modifié.

**free
// ====================================================================
// CALCDEVIS : calcul d'une ligne de devis (exemple 2 Node.js)
// Programme RPG "historique" appelé tel quel par Node.js via
// XMLSERVICE (itoolkit) : aucune modification n'est nécessaire pour
// l'exposer en webservice.
//
// Entrée  : code article (15A), quantité (7P0)
// Sortie  : libellé, PU HT, % remise, total HT, TVA, TTC,
//           code retour (OK / INC / QTE / STK) et message
// Remise  : >= 100 -> 10 %, >= 50 -> 5 %, >= 10 -> 2 %
// ====================================================================
ctl-opt dftactgrp(*no) actgrp(*caller) option(*srcstmt: *nodebugio)
        text('Calcul de devis - demo Node.js');

dcl-pi *n;
  pCode     char(15)      const;
  pQte      packed(7: 0)  const;
  pLibelle  char(80);
  pPuHt     packed(11: 2);
  pRemise   packed(5: 2);
  pTotalHt  packed(13: 2);
  pTva      packed(13: 2);
  pTtc      packed(13: 2);
  pRetour   char(3);
  pMessage  char(80);
end-pi;

dcl-s wLibelle varchar(80);
dcl-s wPrix    packed(11: 2);
dcl-s wTaux    packed(5: 2);
dcl-s wStock   int(10);
dcl-s wBrut    packed(15: 2);

exec sql set option commit = *none, closqlcsr = *endmod;

clear pLibelle;
clear pPuHt;
clear pRemise;
clear pTotalHt;
clear pTva;
clear pTtc;
pRetour  = 'OK';
pMessage = 'Devis calcule';

if pQte <= 0;
  pRetour  = 'QTE';
  pMessage = 'La quantite doit etre superieure a zero';
  return;
endif;

exec sql
  select LIBELLE, PRIX_HT, TAUX_TVA, STOCK
    into :wLibelle, :wPrix, :wTaux, :wStock
    from NODEWS.ARTICLE
   where CODE = upper(trim(:pCode));

if sqlcode = 100;
  pRetour  = 'INC';
  pMessage = 'Article ' + %trim(pCode) + ' inconnu';
  return;
elseif sqlcode < 0;
  pRetour  = 'ERR';
  pMessage = 'Erreur SQL ' + %char(sqlcode);
  return;
endif;

pLibelle = wLibelle;
pPuHt    = wPrix;

select;
  when pQte >= 100;
    pRemise = 10;
  when pQte >= 50;
    pRemise = 5;
  when pQte >= 10;
    pRemise = 2;
  other;
    pRemise = 0;
endsl;

wBrut    = wPrix * pQte;
pTotalHt = %dech(wBrut * (100 - pRemise) / 100: 13: 2);
pTva     = %dech(pTotalHt * wTaux / 100: 13: 2);
pTtc     = pTotalHt + pTva;

// Stock insuffisant : le devis est calculé quand même, avec un avertissement
if pQte > wStock;
  pRetour  = 'STK';
  pMessage = 'Stock insuffisant : ' + %char(wStock) + ' disponible(s)';
endif;

return;

Côté Node.js, itoolkit décrit les dix paramètres dans l'ordre du PI et appelle le programme par XMLSERVICE, en local :

// Exemple 2 : appel du programme RPG NODEWS/CALCDEVIS par XMLSERVICE
// (itoolkit, transport idb : appel en local, dans le travail Node.js).

import itoolkit from 'itoolkit';
import { XMLParser } from 'fast-xml-parser';

const { Connection, ProgramCall } = itoolkit;

// itoolkit recopie les valeurs telles quelles dans le XML : on échappe
// nous-mêmes, sinon un « < » dans une saisie casserait (ou détournerait)
// la requête XMLSERVICE.
export function xmlEscape(valeur) {
  return String(valeur)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll("'", '&apos;')
    .replaceAll('"', '&quot;');
}

// Interface du programme, dans l'ordre des paramètres du PI RPG
export const PARAMETRES = [
  { nom: 'code', type: '15A' },
  { nom: 'quantite', type: '7p0' },
  { nom: 'libelle', type: '80A' },
  { nom: 'prixUnitaireHt', type: '11p2' },
  { nom: 'remisePct', type: '5p2' },
  { nom: 'totalHt', type: '13p2' },
  { nom: 'tva', type: '13p2' },
  { nom: 'totalTtc', type: '13p2' },
  { nom: 'retour', type: '3A' },
  { nom: 'message', type: '80A' },
];

const parser = new XMLParser({ ignoreAttributes: false, parseTagValue: false });

/** Transforme la réponse XMLSERVICE en objet ; lève une erreur si l'appel a échoué. */
export function lireReponse(xml) {
  const doc = parser.parse(xml);
  const pgm = doc?.myscript?.pgm;
  if (!pgm || pgm.error || !pgm.success) {
    const detail = pgm?.error?.errnoxml ?? pgm?.error ?? 'réponse XMLSERVICE illisible';
    throw new Error(`Appel CALCDEVIS en échec : ${JSON.stringify(detail)}`);
  }
  const parms = [pgm.parm].flat();
  const valeurs = {};
  PARAMETRES.forEach((p, i) => {
    const brut = parms[i]?.data?.['#text'] ?? '';
    // XMLSERVICE formate les décimaux selon le travail : « 1450,00 » sur un
    // IBM i en français. Number('1450,00') donnerait NaN.
    valeurs[p.nom] = p.type.endsWith('A') ? brut.trim() : Number(brut.replace(',', '.'));
  });
  return valeurs;
}

export function createDevisService({ lib }) {
  return {
    async calculer(code, quantite) {
      const conn = new Connection({
        transport: 'idb',
        transportOptions: { database: '*LOCAL' },
      });
      const pgm = new ProgramCall('CALCDEVIS', { lib, error: 'off' });
      const entree = { code: xmlEscape(code.toUpperCase()), quantite: String(quantite) };
      for (const p of PARAMETRES) {
        pgm.addParam({ type: p.type, value: entree[p.nom] ?? (p.type.endsWith('A') ? '' : '0') });
      }
      conn.add(pgm);
      const xml = await new Promise((resolve, reject) => {
        conn.run((err, sortie) => (err ? reject(err) : resolve(sortie)));
      });
      return lireReponse(xml);
    },
  };
}
// Exemple 2 : le programme RPG CALCDEVIS exposé en webservice

import { HttpError } from '../errors.js';
import { devis, devisSaisie, erreur } from '../schemas.js';

// Codes retour du programme RPG -> statut HTTP
const STATUT = { OK: 200, STK: 200, INC: 404, QTE: 400 };

export default async function devisRoutes(app, { devisService }) {
  app.post('/devis', {
    schema: {
      tags: ['Exemple 2 - Programme RPG'],
      summary: 'Calcul de devis par le programme RPG NODEWS/CALCDEVIS',
      body: devisSaisie,
      response: { 200: devis, 400: erreur, 404: erreur },
    },
  }, async (request) => {
    const { code, quantite } = request.body;
    const r = await devisService.calculer(code, quantite);
    const statut = STATUT[r.retour] ?? 500;
    if (statut !== 200) throw new HttpError(statut, r.message);
    return { code: code.toUpperCase(), quantite, ...r };
  });
}
Onglet Devis : article, quantité 12, tuiles Prix unitaire HT 59,90 €, Remise 2 %, Total HT 704,42 €, TVA 140,88 €, Total TTC 845,30 €, et une alerte de stock
Le devis est calculé par le RPG : 12 unités donnent 2 % de remise ; le code retour STK devient une alerte.

Exemple 3 Transaction

Un mouvement de stock, tout ou rien

Une sortie de stock, c'est deux écritures : le stock et l'historique. Elles doivent réussir ensemble ou pas du tout. La règle est dans une procédure SQL, sous contrôle de validation ; en cas d'erreur, elle annule tout et renvoie un SQLSTATE que Node.js traduit en statut HTTP.

-- =====================================================================
-- NODEWS.MVT_STOCK : entrée ou sortie de stock en une seule transaction
-- (exemple 3). La règle métier reste dans Db2 : Node.js se contente
-- d'appeler la procédure et de traduire les SQLSTATE en codes HTTP.
--   75001 -> article inconnu        (HTTP 404)
--   75002 -> stock insuffisant      (HTTP 409)
--   75003 -> type ou quantité faux  (HTTP 400)
-- =====================================================================
CREATE OR REPLACE PROCEDURE NODEWS.MVT_STOCK (
  IN  P_ARTICLE_ID  INTEGER,
  IN  P_TYPE        CHAR(1),
  IN  P_QUANTITE    INTEGER,
  IN  P_COMMENTAIRE VARCHAR(100),
  OUT P_STOCK_APRES INTEGER,
  OUT P_MVT_ID      INTEGER
)
LANGUAGE SQL
SPECIFIC NODEWS.MVTSTOCK
MODIFIES SQL DATA
COMMIT ON RETURN YES
SET OPTION COMMIT = *CHG, DBGVIEW = *SOURCE, USRPRF = *USER
BEGIN
  DECLARE V_NB INTEGER DEFAULT 0;

  -- Toute erreur annule l'ensemble (mise à jour du stock + historique)
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  IF P_TYPE NOT IN ('E', 'S') OR P_QUANTITE IS NULL OR P_QUANTITE <= 0 THEN
    SIGNAL SQLSTATE '75003'
      SET MESSAGE_TEXT = 'Type (E ou S) ou quantite (> 0) invalide';
  END IF;

  -- Une seule instruction, donc pas de lecture puis écriture : deux
  -- sorties simultanées ne peuvent pas rendre le stock négatif.
  UPDATE NODEWS.ARTICLE
     SET STOCK = CASE P_TYPE WHEN 'E' THEN STOCK + P_QUANTITE
                             ELSE STOCK - P_QUANTITE END
   WHERE ID = P_ARTICLE_ID
     AND (P_TYPE = 'E' OR STOCK >= P_QUANTITE);

  GET DIAGNOSTICS V_NB = ROW_COUNT;

  IF V_NB = 0 THEN
    IF NOT EXISTS (SELECT 1 FROM NODEWS.ARTICLE WHERE ID = P_ARTICLE_ID) THEN
      SIGNAL SQLSTATE '75001' SET MESSAGE_TEXT = 'Article inconnu';
    ELSE
      SIGNAL SQLSTATE '75002' SET MESSAGE_TEXT = 'Stock insuffisant';
    END IF;
  END IF;

  SELECT STOCK INTO P_STOCK_APRES FROM NODEWS.ARTICLE WHERE ID = P_ARTICLE_ID;

  SELECT ID INTO P_MVT_ID FROM FINAL TABLE (
    INSERT INTO NODEWS.MOUVEMENT
           (ARTICLE_ID, TYPE_MVT, QUANTITE, STOCK_APRES, COMMENTAIRE)
    VALUES (P_ARTICLE_ID, P_TYPE, P_QUANTITE, P_STOCK_APRES,
            COALESCE(P_COMMENTAIRE, ''))
  );
END;

LABEL ON SPECIFIC PROCEDURE NODEWS.MVTSTOCK IS 'Mouvement de stock transactionnel';
// Exemple 3 : mouvements de stock. La transaction (mise à jour du stock +
// historique, tout ou rien) est faite par la procédure SQL MVT_STOCK.

export function createMouvementRepository(db, schema) {
  return {
    async enregistrer(articleId, { type, quantite, commentaire = '' }) {
      const [stockApres, mouvementId] = await db.call(
        `CALL ${schema}.MVT_STOCK(?, ?, ?, ?, ?, ?)`,
        [
          { value: articleId, io: 'in' },
          { value: type, io: 'in' },
          { value: quantite, io: 'in' },
          { value: commentaire, io: 'in' },
          { value: 0, io: 'out' },
          { value: 0, io: 'out' },
        ]);
      return { mouvementId, articleId, type, quantite, stockApres };
    },

    async historique(articleId, limit = 20) {
      return db.query(
        `SELECT ID AS "id", TYPE_MVT AS "type", QUANTITE AS "quantite",
                STOCK_APRES AS "stockApres", COMMENTAIRE AS "commentaire",
                UTILISATEUR AS "utilisateur",
                VARCHAR_FORMAT(DATE_MVT, 'YYYY-MM-DD HH24:MI:SS') AS "date"
           FROM ${schema}.MOUVEMENT
          WHERE ARTICLE_ID = ?
          ORDER BY DATE_MVT DESC, ID DESC
          FETCH FIRST ? ROWS ONLY`,
        [articleId, limit]);
    },
  };
}
Onglet Mouvements : notification Erreur 409 Stock insuffisant, historique avec une seule ligne, l'entrée de 5, profil NODEAPP
Une sortie de 8 pour un stock de 5 : 409, et l'historique ne garde que l'entrée. Le ROLLBACK a tout annulé.

Les bonnes pratiques appliquées

  • Schémas JSON sur chaque route : Fastify refuse un corps mal formé (400) avant tout appel à l'IBM i, et en tire la documentation OpenAPI (/docs).
  • Clé API pour toute écriture, comparée à temps constant, et masquée dans les journaux.
  • En-têtes de sécurité (Helmet), corps limité à 64 Ko.
  • Erreurs homogènes : un SQLSTATE connu devient un statut clair, le reste un 500 sans détail technique ; le détail part dans le journal.
  • Injection de dépendances : l'application se teste sans IBM i.
  • Arrêt propre sur SIGTERM : les requêtes en cours se terminent, le pool est rendu.
  • Profil de service dédié, .env en 600, npm ci avec fichier de verrouillage.
// Fabrique de l'application : les dépendances (base, programme RPG) sont
// injectées, ce qui permet aux tests unitaires de tourner sans IBM i.

import { timingSafeEqual } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import helmet from '@fastify/helmet';
import fastifyStatic from '@fastify/static';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import { errorHandler } from './errors.js';
import { article } from './schemas.js';
import articlesRoutes from './routes/articles.js';
import devisRoutes from './routes/devis.js';
import mouvementsRoutes from './routes/mouvements.js';

const PUBLIC = fileURLToPath(new URL('../public/', import.meta.url));

function cleValide(recue, attendue) {
  const a = Buffer.from(String(recue ?? ''));
  const b = Buffer.from(attendue);
  // Comparaison à temps constant : pas d'attaque par mesure du temps
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function buildApp({ config, deps, logger = true }) {
  const app = Fastify({
    logger: logger && {
      level: config.logLevel,
      redact: ['req.headers["x-api-key"]'],
    },
    bodyLimit: 64 * 1024,
    ajv: { customOptions: { coerceTypes: 'array', removeAdditional: false } },
  });

  app.setErrorHandler(errorHandler);
  app.addSchema(article);

  await app.register(helmet, {
    contentSecurityPolicy: {
      directives: {
        'default-src': ["'self'"],
        'style-src': ["'self'", 'https://fonts.googleapis.com', "'unsafe-inline'"],
        'font-src': ["'self'", 'https://fonts.gstatic.com'],
        'img-src': ["'self'", 'data:'],
        // le site est servi en HTTP sur le réseau interne : pas de upgrade forcé
        'upgrade-insecure-requests': null,
      },
    },
    hsts: false,
  });

  await app.register(swagger, {
    openapi: {
      info: {
        title: 'Webservices Node.js sur IBM i',
        description: 'Trois exemples : CRUD Db2 for i, appel d\'un programme RPG, transaction SQL.',
        version: '1.0.0',
      },
      components: {
        securitySchemes: { cleApi: { type: 'apiKey', in: 'header', name: 'x-api-key' } },
      },
    },
  });
  await app.register(swaggerUi, { routePrefix: '/docs' });

  // Clé API exigée pour toute route déclarée en écriture
  app.addHook('onRequest', async (request, reply) => {
    if (request.routeOptions.config?.ecriture
        && !cleValide(request.headers['x-api-key'], config.apiKey)) {
      reply.code(401).send({
        statusCode: 401, erreur: 'Unauthorized', message: 'Clé API absente ou invalide (en-tête x-api-key)',
      });
    }
  });

  await app.register(async (api) => {
    api.get('/sante', {
      schema: { tags: ['Supervision'], summary: 'État du service et de la base' },
    }, async () => ({
      statut: 'ok',
      node: process.version,
      plateforme: `${process.platform} ${process.arch}`,
      base: await deps.sante(),
      dureeMarche: Math.round(process.uptime()),
    }));
    await api.register(articlesRoutes, deps);
    await api.register(devisRoutes, deps);
    await api.register(mouvementsRoutes, deps);
  }, { prefix: '/api' });

  // Écran de test (design IBM Carbon)
  await app.register(fastifyStatic, { root: PUBLIC, prefix: '/' });

  return app;
}
Interface Swagger UI listant les routes des trois exemples, regroupées par tag
La documentation OpenAPI est générée depuis les schémas : rien à écrire en plus.

Les tests : unitaires, IBM i, Chrome, Postman

Tests unitaires, avec le lanceur intégré à Node.js (node:test) : des doublures en mémoire remplacent Db2 et le RPG, ils tournent sur un PC en moins d'une seconde.

# tests 39
# pass 39
# fail 0
# duration_ms 799.2135

Tests d'intégration, lancés sur l'IBM i : vraie table, vrai RPG, vraie procédure, accents compris.

  ✔ santé : connexion *LOCAL (32.276291ms)
  ✔ Exemple 1 : création, lecture, doublon, modification (60.607349ms)
  ✔ Exemple 1 : contrainte CHECK Db2 -> SQLSTATE 23513 (14.177099ms)
  ✔ Exemple 2 : programme RPG CALCDEVIS via XMLSERVICE (84.255697ms)
  ✔ Exemple 2 : article inconnu -> 404 renvoyé par le RPG (76.680668ms)
  ✔ Exemple 3 : entrée puis sortie, historique (47.087985ms)
  ✔ Exemple 3 : stock insuffisant -> 409 et rien n'est écrit (ROLLBACK) (10.858983ms)
  ✔ Exemple 3 : article inconnu -> 404 (SIGNAL 75001) (3.573526ms)
  ✔ Exemple 1 : suppression refusée tant qu'il y a des mouvements (clé étrangère) (6.401603ms)
  ✔ requêtes concurrentes : 20 lectures en parallèle sur le pool (141.883452ms)
✔ Db2 for i réel (891.617043ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0

Tests dans Chrome : Playwright pilote l'écran de test (création sans clé, création, doublon, modification, recherche, devis, mouvements, suppression refusée), 11 sur 11.

Écran de test : notification verte Article créé, le nouvel article apparaît dans le tableau
Création depuis Chrome : 201, et le journal montre le 401 de la tentative sans clé juste avant.

Postman : une collection de 17 requêtes et 58 assertions, créée dans Postman puis exécutée par newman depuis le poste.

Postman : collection NODEWS avec ses dossiers Supervision, Articles, Devis, Mouvements, et les tests de la requête Devis 12 unités
La collection dans Postman, et les tests d'une requête.
  √  409 tant qu'il existe des mouvements

┌─────────────────────────┬───────────────────┬──────────────────┐
│                         │          executed │           failed │
├─────────────────────────┼───────────────────┼──────────────────┤
│              iterations │                 1 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│                requests │                17 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│            test-scripts │                34 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│      prerequest-scripts │                 1 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│              assertions │                58 │                0 │
├─────────────────────────┴───────────────────┴──────────────────┤
│ total run duration: 1985ms                                     │
├────────────────────────────────────────────────────────────────┤
│ total data received: 4.46kB (approx)                           │
├────────────────────────────────────────────────────────────────┤
│ average response time: 34ms [min: 11ms, max: 88ms, s.d.: 27ms] │
└────────────────────────────────────────────────────────────────┘
Rapport newman : 1 itération, 58 assertions, 0 test en échec, 0 ignoré
Le rapport newman : 58 assertions, aucune en échec.

Les erreurs rencontrées

Les deux premières sont apparues lors d'une installation depuis QSH, sur un IBM i 7.4 de production ; les autres, dans l'ordre où elles sont apparues pendant la mise en place sur l'IBM i de formation. Deux d'entre elles (les DECIMAL en chaînes, l'injection XML) ont été repérées en lisant le code des paquets, avant d'écrire l'application : elles n'ont donc pas eu le temps de se produire.

Message ou symptômeCauseCorrectif
qsh: 001-0019 Error found searching for command yumQSH ne cherche pas dans /QOpenSys/pkgs/bin, où yum installe ses commandes.Vérifier par ls -l /QOpenSys/pkgs/bin/yum, puis export PATH=/QOpenSys/pkgs/bin:$PATH.
yum : curl#6 - "getaddrinfo() thread failed to start", puis [Errno 256] No more mirrors to tryLe travail QSH n'accepte pas plusieurs threads ; curl en a besoin pour résoudre le nom du dépôt.Quitter QSH par F3, ADDENVVAR ENVVAR(QIBM_MULTI_THREADED) VALUE(Y), puis STRQSH. Ou passer par QP2TERM ou SSH.
bsh: PATH=…: is not an identifierLe shell par défaut du profil est bsh, un Bourne ancien qui refuse « export VAR=valeur ».Lancer les commandes par /QOpenSys/pkgs/bin/bash -c, ou passer le profil en bash (chsh).
SQL0104 sur LABEL ON SCHEMALABEL ON SCHEMA n'existe pas en Db2 for i.Texte de la bibliothèque par CHGOBJD, appelé en SQL par CALL QSYS2.QCMDEXC.
SQL7029 : le nom système ARTICLE ne peut pas être spécifiéFOR SYSTEM NAME donné alors que le nom SQL est déjà un nom système valide.Ne mettre FOR SYSTEM NAME que si le nom SQL dépasse 10 caractères.
SQL5051 : qualificatif SAK différent du nom NODEWSLes noms de contraintes non qualifiés prennent le schéma par défaut du travail, ici le profil.SET SCHEMA NODEWS avant les CREATE TABLE.
Cannot find module …\test\unitnode --test ne prend pas un répertoire en argument.Un motif : node --test "test/unit/**/*.test.js".
npm warn install-scripts … idb-connector (install: node-gyp-build)npm 11.19 n'exécute plus les scripts d'installation des paquets non approuvés.Ici sans conséquence : le binaire IBM i est livré dans prebuilds/. Le vérifier par un require et une requête.
Réponse 500, totaux à NaNXMLSERVICE renvoie les décimaux au format du travail : « 1450,00 » sur un système en français.Remplacer la virgule par un point avant Number(). Test unitaire ajouté.
Prix en chaînes de caractères dans le JSONidb-pconnector rend les DECIMAL en chaînes par défaut.enableNumericTypeConversion: true à la création du DBPool.
Injection XML possibleitoolkit recopie les valeurs telles quelles dans le XML envoyé à XMLSERVICE.Échapper & < > ' " soi-même, et valider le code article par un schéma (lettres, chiffres, tiret).
Postman : « Sending request… » sans finL'agent cloud de Postman part de serveurs hors de France ; le filtrage en amont de la machine ne laisse passer que des adresses françaises.Exécuter la collection depuis le poste, par newman (ou le Postman CLI connecté).
curl : CRYPT_E_NO_REVOCATION_CHECK (0x80092012)Le certificat vient d'une autorité interne qui ne publie pas de liste de révocation joignable ; Windows (schannel) refuse par défaut.Le certificat est bien reconnu : curl.exe --ssl-no-revoke pour un test. Chrome n'est pas gêné.
newman : erreur de certificat en HTTPSnewman tourne sous Node.js, qui ne lit pas le magasin de certificats de Windows : l'autorité interne lui est inconnue.NODE_OPTIONS=--use-system-ca (Node.js 22 et plus).
Clé API en clair dans le rapport newmanLe rapport HTML recopie les en-têtes des requêtes.Masquer la clé dans les rapports avant de les partager.

Sources

Consultées le 27 octobre 2026.

  1. About Node.js, OpenJS Foundation. Le modèle de Node.js : une boucle d'événements, des entrées-sorties non bloquantes.
  2. Node.js, Wikipédia (en). Ryan Dahl, 2009, présentation à la JSConf EU, moteur V8, npm en 2010, Node.js Foundation puis OpenJS Foundation.
  3. Node.js now available, RPGPGM.COM, décembre 2014. Node.js livré pour IBM i le 15 décembre 2014 : 5733-OPS option 1, prérequis PASE (5770-SS1 option 33), annoncé avec le TR9 de la 7.1 et le TR1 de la 7.2.
  4. Open source the Node.js port for IBM i, IBM Ideas. Réponse d'IBM (2018) : les modifications propres à IBM i sont dans le dépôt officiel de Node.js depuis la version 10.
  5. Node.js usage notes, IBM i OSS Docs. Installation par yum, choix d'une version paire LTS, changement de version par alternatives, paquets odbc, idb-connector, idb-pconnector et itoolkit.
  6. nodejs-idb-pconnector, IBM (GitHub). Le pool de connexions DBPool, prepareExecute et l'option enableNumericTypeConversion.
  7. nodejs-itoolkit, IBM (GitHub). ProgramCall et le transport idb vers XMLSERVICE.
  8. Fastify, OpenJS Foundation. Validation par schéma JSON, sérialisation, injection de requêtes pour les tests.
  9. Carbon Design System, IBM. Le design d'IBM repris par l'écran de test : couleurs, IBM Plex, composants.

Questions fréquentes

Quelle version de Node.js installer sur IBM i ?

La plus récente des versions paires en support long (LTS) proposées par le dépôt yum d'IBM. Le 27 octobre 2026, le dépôt ibmi-base proposait nodejs14 à nodejs24 : nodejs24 (24.22.1) a été installé. Les versions impaires ne sont pas faites pour la production.

Peut-on installer Node.js depuis une session 5250, sans SSH ?

Oui, dans QSH (STRQSH), si l'open source est déjà amorcé sur la machine. Il faut ajouter /QOpenSys/pkgs/bin au PATH (export PATH=/QOpenSys/pkgs/bin:$PATH), autoriser les threads avant de lancer QSH (ADDENVVAR ENVVAR(QIBM_MULTI_THREADED) VALUE(Y)), puis lancer yum -y install nodejs24 nodever avec un profil qui a *ALLOBJ. CALL QP2TERM fonctionne aussi.

Pourquoi yum répond « getaddrinfo() thread failed to start » ?

Parce que yum est lancé dans un travail QSH qui n'accepte pas plusieurs threads, alors que curl résout le nom du dépôt d'IBM dans un thread. Ce n'est pas un problème de réseau. Quitter QSH par F3, lancer ADDENVVAR ENVVAR(QIBM_MULTI_THREADED) VALUE(Y) REPLACE(*YES), puis STRQSH, ou passer par QP2TERM ou SSH.

Faut-il Apache ou l'HTTP Server d'IBM devant Node.js ?

Ce n'est pas obligatoire : Node.js écoute lui-même sur son port. Mais c'est la façon la plus simple d'avoir du HTTPS : une instance de l'IBM HTTP Server en reverse proxy chiffre sur le port 3443 avec un certificat DCM et relaie à Node.js, qui n'écoute plus que sur 127.0.0.1:3030.

Comment Node.js se connecte-t-il à Db2 for i ?

Avec idb-connector ou idb-pconnector, des paquets npm d'IBM qui appellent Db2 directement depuis PASE, dans le travail de Node.js : la connexion *LOCAL ne demande pas de mot de passe, elle a les droits du profil du travail. Pour une connexion depuis une autre machine, on prend le paquet odbc avec le pilote ODBC d'IBM i Access.

Peut-on appeler un programme RPG existant depuis Node.js ?

Oui, avec le paquet itoolkit, qui passe par XMLSERVICE (bibliothèque QXMLSERV, livrée avec le système). Le programme RPG n'est pas modifié : on décrit ses paramètres (15A, 7p0, 13p2...) et on récupère les valeurs de retour. Sur un système en français, les décimaux reviennent avec une virgule.

Sous quel profil faire tourner Node.js ?

Sous un profil de service dédié, sans mot de passe ni droit spécial, qui a seulement les droits utiles sur la bibliothèque de l'application. Dans les exemples, le profil NODEAPP a *CHANGE sur les tables et *USE sur le programme RPG et la procédure ; tout le reste de la bibliothèque est en *PUBLIC *EXCLUDE.

Comment lancer Node.js comme un service sur IBM i ?

Avec Service Commander (paquet service-commander) : un fichier YAML décrit la commande, le répertoire, le port à surveiller et les options du SBMJOB (file de travaux, profil). « sc start nodews » soumet alors un travail batch, « sc check nodews » vérifie qu'il écoute.