Nous allons réaliser un lecteur RSS en lignes de commande. A travers ce projet, nous verrons les aspects suivants d’un projet TypeScript:

  • Installation de NodeJS et de NPM
  • Installation du compilateur TypeScript
  • Conception d’interfaces TypeScript, séparation des responsabilités
  • Implémentation de classes TypeScript
  • Installation de Jest pour TypeScript
  • Tester une classe TypeScript avec Jest
  • Isolation des dépendances avec des mocks
  • Intégration continue avec Gitlab
  • Ajout de scripts NPM (ex: npm run test)
  • Compilation du projet TypeScript (vers Javascript)

L’objectif de notre projet est de récupérer une liste d’articles en ligne à partir de différents flux RSS, puis de les afficher sur la console.

Installation de Node et du compilateur TypeScript

Installation de Node et NPM

La méthode d’installation que je vous propose consiste à télécharger une archive NodeJS compilée depuis le site officiel avec wget puis de l’extraire dans le répertoire /usr/local. Cette archive contient aussi NPM qui est le gestionnaire de paquets de NodeJS.

wget https://nodejs.org/dist/v16.14.0/node-v16.14.0-linux-x64.tar.xz -O node-v16_14_0.tar.xz
tar --strip-component=1 -xJf node-v16_14_0.tar.xz -C /usr/local

Vous pouvez vérifier l’installation avec node --version.

Installation de TypeScript

Maintenant qu’on dispose de NPM, on va pouvoir installer le compilateur TypeScript et initialiser sa configuration. Tout d’abord, on se place dans le dossier qui va héberger le projet, puis:

  • npm i --save-dev typescript permet d’installer le compilateur dans le projet
  • npx tsc --init crée un fichier de configuration par défaut dans tsconfig.json

Ces deux commandes ont ajouté trois fichiers au dossier courant:

  • tsconfig.json contient la configuration de tsc (TypeScript Compiler)
  • package.json liste les packages dont dépend le projet (seulement typescript pour l’instant)
  • package-lock.json est un fichier généré par NPM qui contient la résolution de l’arbre des dépendances issues des packages de package.json

Avant propos: comment fonctionne un flux RSS

RSS permet de lister les articles publiés sur un site internet dans un format structuré dérivé du XML. On va analyser la spécification de RSS pour concevoir notre application.

Voici les éléments obligatoires d’un flux RSS: à la racine du document XML, on va trouver un élément channel qui décrit les caractéristiques du flux représenté dans le document. L’élément channel présente les propriétés suivantes obligatoires:

  • title: le titre du flux RSS
  • link: l’URL du site associé à ce flux RSS
  • description: la description de ce flux RSS

L’élément channel contient un ensemble d'item qui correspondent chacuns à une ressource du site internet spécifié dans l’élément channel. Chaque item contient également une liste d’élements obligatoires qui sont les mêmes que pour channel:

  • title: le titre de la ressource du flux
  • link: le lien de la ressource
  • description: la description liée à la ressource

Conception logicielle et séparation des responsabilités

A ce stade de réflexion, on peut découper l’application en trois classes qui auront chacune une responsabilité:

  • Article
  • Flux
  • Console

Un article devra pouvoir être affiché sous forme de texte et présenter un titre optionnel et un lien.

Un flux sera capable de lister les articles qu’il contient.

La console affichera les articles uniques provenant des flux précisés par l’utilisateur à l’écran.

On va maintenant définir les opérations que devront être capable de réaliser ces classes avec des interfaces TypeScript.

Notez que ces notions ne sont pas spécifiques à RSS. On pourrait très bien lire des articles à partir d’une page HTML listant les articles récents du blog par exemple, et implémenter une classe Flux pour un flux provenant d’une page web.

Définition d’un article

Un article devra être capable d’afficher son contenu. De plus, on pourra comparer deux articles en terme d’égalité pour ne pas afficher deux fois un article qui serait présent dans deux flux différents.

Le type Writable désigne un objet qui implémente l’API stream de NodeJS . La sortie standard est un stream dans lequel on peut écrire: un Writable. On implémentera par la suite un Writable qui nous servira pour tester l’affichage de l’article.

export interface Article {
  affiche(sortie: WritableStream): void;
  egal(autreArticle: Article): boolean;
}

Définition d’un flux

Un flux devra être capable de retourner les articles qu’il contient. getArticles renvoie une Promise, ce qui permet de récupérer les articles de manière asynchrone.

import { Article } from './article'

export interface Flux {
  getArticles(): Promise<Article[]>;
}

Définition de la console

La console devra afficher les articles à l’écran. On pourra lui ajouter des flux.

import { Flux } from './flux';

interface Console {
  ajouteFlux(flux: Flux): void;
  afficheTousFlux(): void;
}

Vous pouvez explorer le projet à ce stade de l’article au commit suivant ➡️ feat: définition d’interfaces pour le lecteur d’articles .

Je suis la convention des commits conventionnels dans la suite de cet article. J’ai déjà expliqué ce mode de fonctionnement dans un toot Mastodon, n’hésitez pas à me rejoindre sur ce réseau 🌟

Implémentation d’un Flux RSS

On va en premier lieu créer une classe FluxRSS qui implémente l’interface Flux. On crée également la classe ArticleRSS qui est pour l’instant une implémentation vide de l’interface Article.

NPM contient toutes sortes de librairies pour des multitudes d’usages. Avant de coder quoi que ce soit, il est bienvenu de rechercher si une librairie correspondante existe.

Pour lire un flux RSS, on va utiliser la librairie rss-parser qui va retourner le contenu du flux sous forme d’objets TypeScript. Pour l’installer, lancez npm i --save rss-parser.

Notre implémentation d'ArticleRSS va lire le flux RSS distant lors de la construction de l’objet et stocker une promesse des articles lus dans ce flux dans la variable privée articles.

import Parser from 'rss-parser';

export class FluxRSS implements Flux {
  private articles: Promise<Article[]>;
  private rssParser = new Parser();

  constructor(url: string) {
    this.articles = this.getArticlesFromUrl(url);
  }

  public getArticles(): Promise<Article[]> {
    return this.articles;
  }

  private async getArticlesFromUrl(url: string): Promise<Article[]> {
    const feed = await this.rssParser.parseURL(url);
    return feed.items.map((item) => new ArticleRSS(item));
  };
}

Cette classe sera couplée à la classe ArticleRSS, dont on définit une implémentation vide pour qu’elle réponde à l’interface Article:

export class ArticleRSS implements Article {
  constructor(item: Parser.Item) {};

  affiche(sortie: WritableStream) {};

  egal(autreArticle: Article) { return false };
}

On réalise un commit contenant cet avancement: feat: implémentation d’un Flux RSS

Test de l’implémentation

Installation de Jest

Pour tester notre projet TypeScript, nous allons utiliser Jest, pour des raisons déjà évoquées

Pour utiliser Jest avec TypeScript, on va installer Jest plus le module ts-jest : npm i --save-dev jest @types/jest ts-jest. Le module ts-jest va nous permettre de générer une configuration Jest compatible avec TypeScript de manière automatique avec la commande npx ts-jest config:init.

Jest a été écrit en JavaScript et non en TypeScript. Le package @types/jest contient la définition TypeScript du package Jest et permet de rendre Jest compatible avec TypeScript. Plus d’informations ici.

Les changements du dépôt Git sont les suivants: test: installation de Jest

Implémentation d’un premier test

Nous allons maintenant vérifier qu’un FluxRSS construit à partir d’une URL utilise bien la librairie rss-parser pour construire sa liste d’articles.

De plus, lorsque le Parser de rss-parser renvoie une liste d’articles vide, la méthode getArticles du FluxRSS devra également renvoyer une promesse dont le résultat sera une liste d’articles vide.

Pour cela, nous allons créer un mock du Parser pour qu’il renvoie systématiquement une liste d’articles vide:

import Parser from 'rss-parser';

jest.genMockFromModule('rss-parser');
jest.mock('rss-parser');

const mockParser = {
  parseURL: jest.fn().mockReturnValue({ items: [] })
};

(Parser as jest.Mock).mockImplementation(() => mockParser);

Ensuite, ajoutons un premier test qui s’assure que lors de sa construction, FluxRSS utilise bien le Parser:

describe('La classe FluxRSS', () => {
  it('doit parser le flux passé en argument', () => {
    const urlExemple = 'https://example.com/index.xml';
    new FluxRSS(urlExemple);

    expect(mockParser.parseURL.mock.calls[0][0]).toBe(urlExemple);
  })
})

Puis dans un second temps, vérifions que le FluxRSS nous renvoie bien dans ce cas une liste d’articles vides lors de l’appel à sa méthode getArticles:

  it('doit pouvoir retourner liste d\'articles vide', async () => {
    const urlExemple = 'https://example.com/index.xml';
    const flux = new FluxRSS(urlExemple);

    let articles = await flux.getArticles();

    expect(articles).toEqual([]);
  })

🎉 test: FluxRSS peut renvoyer une liste d’articles vide

Intégration Continue avec Gitlab

On va maintenant ajouter l’intégration continue avec Gitlab. Pour cela, on va simplement demander à Gitlab de nous provisionner un conteneur Docker basé sur l'image Docker de la version LTS de NodeJs qu’on a installé au début du projet: node:16.14.0. Notre pipeline aura une seule étape, les tests unitaires lancés par la commande npm run test. On devra au préalable avoir installé les dépendances NPM.

tests_unitaires:
  image: node:16.14.0
  stage: test
  before_script:
    - npm ci
  script:
    - npm run test

🚀 ci: tests unitaires

Implémentation d’un Article RSS

On va maitenant réaliser les méthodes affiche et égal d'ArticleRSS pour valider le contrat de l’interface Article.

Résolution d’une coquille dans l’interface Article

La méthode affiche de l’interface Article prend en fait un objet Writable et non WritableStream en argument. Petit commit de correction: fix(article): utilisation de Writable dans l’interface 👀

Utilitaire de test pour récupérer l’affichage de l’article

A terme, on utilisera la sortie standard process.stdout en argument de la méthode affiche de l’article, pour afficher l’article sur la console. Mais pour nos tests, on préfère simplement récupérer le résultat de la méthode affiche dans une chaîne de caractères.

Pour cela, on implémente un utilitaire, WritableGetSortie qui implémente l’interface Writable et a une méthode getSortie qui permet de récupérer ce qui a été écrit dans l’objet.

Voici notre nouvel outil test(article): classe Writable utilitaire pour récupérer la sortie 🔧

On s’assure que l’utilitaire WritableGetSortie se comporte bien comme décrit ci-dessus avec des tests:

import { WritableGetSortie } from './writable';

describe('La classe WritableGetSortie', () => {
  const writable = new WritableGetSortie();
  beforeEach(() => writable.reset());

  it('Doit pouvoir renvoyer son entrée', () => {
    const message = 'Récupération des données';

    writable.write(message);

    expect(writable.getSortie()).toBe(message);
  })

  it('Doit pouvoir renvoyer la somme de plusieurs entrées', () => {
    const messageA = 'Récupération des données';
    const messageB = '\nCeci est un second message';

    writable.write(messageA);
    writable.write(messageB);

    expect(writable.getSortie()).toBe(messageA + messageB);
  })
})

Implémentation de la méthode affiche de l’article

On va pouvoir passer à l’implémentation de l’affichage de l’article. Pour cela, on définit d’abord la spécification suivante pour le texte affiché par l’article:

  • Le texte contient le titre et le lien en entier
  • Il doit au moins contenir les premiers mots de la description
  • Le dernier caractère imprimé doit être un retour à la ligne

Cette spécification est réalisée dans test(article): test de la méthode affiche de ArticleRSS

Passons maintenant à l’implémentation de la méthode affiche:

export class ArticleRSS implements Article {
  private item: Parser.Item;

  constructor(item: Parser.Item) {
    this.item = item;
  };

  affiche(sortie: Writable) {
    this.afficheElement(sortie, 'Titre', this.item.title);
    this.afficheElement(sortie, 'Lien', this.item.link);
    this.afficheElement(sortie, 'Description', this.item.content);
  };

  private afficheElement(sortie: Writable, header: string, content: string | undefined) {
    sortie.write(`${header}: `);
    sortie.write(content);
    sortie.write(`\n`);
  }

  egal(autreArticle: Article) { return false };
}

Et voilà, l’article pourra maintenant être affichée sur la sortie standard de la console par exemple. Il ne reste plus qu’à implémenter la méthode egal pour remplir le contrat de l’interface Article.

feat(article): implémentation de la méthode affiche pour ArticleRSS

Implémentation de la méthode egal d’ArticleRSS

Cette méthode va nous servir pour comparer deux articles (et savoir si ils font référence à la même chose). On pourra ainsi afficher les articles unique à l’écran, et éliminer les doublons (par exemple deux mêmes articles dans deux flux différents).

On ajoute aussi la méthode getId à l’interface Article, ce qui va nous permettre de récupérer l’identifiant unique de l’article à des fins de comparaison.

export interface Article {
  affiche(sortie: Writable): void;
  egal(autreArticle: Article): boolean;
  getId(): string | undefined;
}

L’id d’un article RSS est le premier attribut non undefined de l'item lu par le Parser RSS parmi la liste suivante: guid, link, content.

Pour la comparaison en égalité, on renvoie faux si l’id d’un des deux articles est undefined, sinon on compare leurs deux ids.

  egal(autreArticle: Article) {
    if (autreArticle.getId() == undefined || this.getId() == undefined) {
      return false;
    }
    return autreArticle.getId() == this.getId();
  };

  public getId(): string | undefined {
    return this.item.guid || this.item.link || this.item.content;
  }

feat(article): implémentation de la méthode egal/getId d’ArticleRSS

On implémente aussi un test basique pour la méthode egal: test(article): un article doit être égal à lui-même.

Implémentation de la console

La classe ConsoleStdout devra afficher les articles des flux qu’on lui a assigné via la méthode ajouteFlux sur la sortie standard.

Avant d’afficher un article, on vérifiera qu’il n’a pas déjà été affiché avec sa méthode egal. On stocke les articles affichés par ConsoleStdout dans son attribut privé articlesAffiches.

Pour récupérer les articles de tous les flux dans une liste, on utilise la méthode map de la liste des flux pour récupérer une liste de promesses d’articles. On passe cette liste de promesses à Promise.all qui va attendre la réalisation de toutes les promesses passées en argument. Cela donne une liste de liste d’articles que l’on transforme en une liste simple d’articles avec la méthode flat.

export class ConsoleStdout implements Console {
  private output: Writable = process.stdout;
  private flux: Flux[] = [];
  private articlesAffiches: Article[] = [];

  public ajouteFlux(flux: Flux): void {
    this.flux.push(flux);
  };

  public async afficheTousFlux(): Promise<void> {
    const articles = (
      await Promise.all(this.flux.map((f) => f.getArticles()))
    ).flat();

    for(const article of articles) {
      if (this.articleDejaAffiche(article)) {
        continue;
      } else {
        this.afficheArticle(article);

        this.articlesAffiches.push(article);
      }
    }
  };

  private afficheArticle(article: Article) {
    this.output.write(`Article n° ${this.articlesAffiches.length + 1}\n`);
    article.affiche(this.output);
    this.output.write('------\n\n');
  };

  private articleDejaAffiche(article: Article) {
    return !!this.articlesAffiches.find((a) => a.egal(article));
  };
}

On réalise également des tests de la console pour vérifier qu’elle affiche les articles de manière unique.

feat(console): ConsoleStdout affiche les flux sur la sortie standard

Compilation du projet dans dist

Par défaut, npx tsc qui compile le projet de Javascript vers TypeScript va stocker le code Javascript obtenu dans un fichier adjacent au code Typescript. Par exemple, src/article.ts sera compilé vers src/article.ts.

Cela pollue le projet. Pour y remédier, on va modifier la configuration TypeScript tsconfig.json pour préciser que le répertoire de sortie doit être dist.

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "./dist", // Specify an output folder for all emitted files.
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Il est aussi nécéssaire de préciser à Jest que les fichiers de tests se situent dans src et test uniquement, sans quoi Jest utilisera aussi les fichiers compilés dans dist:

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['src', 'test'],
};

Maintenant qu’on a configuré la compilation de TypeScript, on peut créer un script NPM associé, nommé build:

  "scripts": {
    "build": "tsc",
    "test": "jest"
  },

feat: compilation du projet dans le dossier dist

Finalisation du projet

On va maintenant utiliser ConsoleStdout et FluxRSS pour afficher les articles de mon blog et ceux du Journal du Hacker.

import { ConsoleStdout } from "./src/console";
import { FluxRSS } from "./src/flux";

const urlsRSS = [
  'https://www.journalduhacker.net/rss',
  'https://sagot.dev/index.xml'
];

const affichage = new ConsoleStdout();

urlsRSS
  .map((u) => new FluxRSS(u))
  .forEach((f) => affichage.ajouteFlux(f));

affichage.afficheTousFlux();

On ajoute un script NPM (ligne de commande: npm run start) qui lance le script TypeScript compilé en Javascript réalisé ci-dessus:

  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "jest"
  },

🎆 feat: npm run start affiche deux flux RSS sur la console

Voici le résultat quand on lance npm run start:

Flux RSS dans une console

Conclusion

J’espère que cet article vous aura aidé à vous lancer dans le développement d’applications avec TypeScript. Il peut être laborieux de lancer un projet avec ce langage étant donné le nombre de nouvelles notions à assimiler. Vous savez maintenant comment initialiser un projet TypeScript, installer des librairies, compiler et tester le code produit.