Veille

Comment migrer du contenu vers Strapi ?

  • Date de l’événement 13 Jun. 2023
  • Temps de lecture min.

La migration de contenu est courante lorsqu'on remplace un CMS par un autre. Dans cet article, nous allons voir comment migrer du contenu vers Strapi.
Pour toute migration de données, nous devons d'abord définir la source et la destination. La source peut être une base de données, une API, des fichiers (CSV, JSON, etc.)... en fonction de l'origine de vos données. Dans notre cas, la destination est Strapi.
Comme la source sera différente pour chaque migration de données, nous n'aborderons pas ce sujet dans cet article, et nous nous concentrerons sur la destination : Strapi.
Alors, comment pouvons-nous migrer du contenu dans Strapi ?

Comment pouvons-nous migrer du contenu dans Strapi ?

La première étape consiste à définir les types de contenu dans Strapi qui accueilleront le contenu que nous souhaitons migrer.
Dans notre exemple, nous utiliserons la démo FoodAdvisor qui définit plusieurs types de contenu, notamment "restaurant" et "article".
La prochaine étape consiste à trouver une méthode technique pour la création massive de contenu dans Strapi.

 

Comment créer du contenu avec Strapi

Nous pourrions importer directement les données dans la base de données. Cependant, cela signifie que nous devrions connaître sa structure. La structure de la base de données est gérée directement par Strapi en fonction des types de contenu déclarés dans Strapi. Bien que cela puisse être assez simple pour des types de contenu vraiment simples, cela devient complexe lorsque l'on commence à utiliser des relations, des composants et des zones dynamiques. Nous n'utiliserons pas cette approche ici.
Nous pourrions utiliser la nouvelle fonctionnalité d'import de données de Strapi (disponible à partir de la version 4.6.0), mais elle n'était pas disponible lorsque nous avons commencé à chercher une solution. De plus, étant donné qu'elle est initialement conçue pour l'export/import d'une instance Strapi vers une autre instance Strapi, nous devrions rétro-ingéniérer la structure de cet export (sous la forme de fichiers JSON Lines) pour la faire fonctionner. La principale difficulté consisterait à gérer manuellement les identifiants (les créer et les conserver en mémoire pour créer les relations entre les contenus).
Nous pourrions également utiliser l'API Query Engine ou l'API Entity Service, mais cela nécessiterait de coder le script de migration dans l’environnement d’exécution de Strapi, par exemple via un plugin. Cela semblait trop compliqué à réaliser, surtout pour quelque chose qui serait supprimé de Strapi une fois la migration terminée.
Nous avons donc décidé d'utiliser l'API Rest. Après tout, Strapi est un CMS headless et est donc conçu API-first. Maintenant, quelle API Client pouvons-nous utiliser et quelles données pouvons-nous inclure dans le corps de ces appels API ?

 

Client API Rest de Strapi

Bien qu'il existe des clients API REST pour Strapi, tels que strapi-client-js ou strapi-sdk-js, aucun d'entre eux n'avait toutes les fonctionnalités dont nous avions besoin : la création de contenu et de médias avec la possibilité de créer des répertoires de médias et de déplacer des médias dans des répertoires.
Alors que la création de médias utilise l'API traditionnelle, la création d'un répertoire de médias et le déplacement de médias dans des répertoires nécessitent l'utilisation d'une API spéciale fournie par le plugin d'upload et nécessitent une méthode d'autorisation différente (le jeton API habituel ne fonctionne pas dans ce cas et un jeton JWT doit être utilisé à la place).
À cette fin, nous avons développé @smile/strapi-client. Son utilisation est assez simple :

import {StrapiClient} from '@smile/strapi-client';
const strapiClient = new StrapiClient('http://127.0.0.1:1337', 'token', 'admin_token');

Le constructeur accepte 2 jetons :

  • Le premier est le jeton d’API habituel généré dans Strapi > Paramètres > Jetons d’API.
  • Le deuxième est facultatif (utile pour créer des dossiers de médias ou déplacer des médias dans la bibliothèque de médias) et doit être un jeton JWT utilisé par l'application web d'administration Strapi. Ce jeton peut être trouvé dans le stockage local ou de session du navigateur après s'être authentifié sur l'administration Strapi.
    Ensuite, le client Strapi permet :
  • Création d'une entrée : strapiClient.createEntry('articles', {...});
  • Mise à jour d'une entrée : strapiClient.updateEntry('articles', articleId, {...});
  • Création d'un média : strapiClient.addMediaAsset('pathOrUrl', 'alt', 'caption');
    • Si une URL est fournie, le fichier est automatiquement téléchargé.
  • Création d'un dossier de médias : strapiClient.createMediaFolder('nomDuDossier', parentId);
  • Déplacement de plusieurs médias vers un dossier : strapiClient.moveMedia(folderId, [mediaId1, mediaId2, ...]);

Pour les entrées, les données transmises à l'API doivent correspondre aux types de contenu de Strapi. Mais comment pouvons-nous aider le développeur à transmettre les données correctes ?

 

Génération de types

Afin de transmettre à l'API Strapi un payload correspondant aux types de contenu attendus, nous devrions créer des interfaces TypeScript qui décrivent les champs et leurs types pour chaque type de contenu afin d'aider lors de la phase de développement. Cependant, cette tâche est laborieuse et sujette aux erreurs, c'est pourquoi nous aimerions l'automatiser.
Strapi fournit une commande pour générer des types (strapi ts:generate-types), mais les types générés ne correspondent pas à ceux attendus pour les appels API.
Nous avons donc développé notre propre outil (strapi-content-type-to-ts). Pour le type de contenu "restaurant" de la démo FoodAdvisor, cela générerait quelque chose comme cela pour le type de contenu "restaurant" (simplifié) :

export interface Restaurant {
  name: string;
  slug?: string;
  images: { id: number }[];
  price: (`p1` | `p2` | `p3` | `p4`);
  ...
}

Notez que cet outil peut être étendu pour gérer les champs personnalisés fournis par les plugins de Strapi.

Partie pratique

Introduction

Comme expliqué précédemment, notre exemple est basé sur la démo FoodAdvisor. Assurez-vous d'avoir d'abord cloné ce repository. Étant donné que le référentiel est assez volumineux, vous pouvez cloner uniquement le dernier commit avec la commande suivante : git clone --depth 1 git@github.com:strapi/foodadvisor.git


Cette démo comporte deux répertoires principaux :

  • "api", correspondant à la partie Strapi
  • "client", correspondant à une application web Next.js pour la démo


Assurez-vous d'avoir installé les deux parties (api et client) et d'avoir exécuté la commande "seed" dans la partie "api". Ensuite, démarrez Strapi (et Next.js si vous souhaitez avoir une démo de l'interface utilisateur).
Pour notre manipulation, nous allons créer un nouveau projet Node en dehors de la démo FoodAdvisor. Cependant, vous pouvez également le développer à l'intérieur de la démo FoodAdvisor (avec quelques modifications de configuration).

 

Conseils pour le développement dans me répertoire Strapi

Si vous décidez de développer dans le projet Strapi de démonstration de FoodAdvisor, vous devez savoir que Strapi, lorsqu'il est lancé en mode développement, redémarre et rebuild automatiquement lorsqu'il détecte des modifications de fichiers. Pour éviter les redémarrages intempestifs lors du développement du script de migration, vous devez le désactiver pour le répertoire dans lequel vous développez votre script de migration.


Modifier le fichier config/admin.js :

module.exports = ({ env }) => ({
  ...
  watchIgnoreFiles: ['**/yourMigrationDirectory/**'],
});

De plus, si votre projet Strapi utilise TypeScript (ce n'est pas le cas par défaut dans la démo FoodAdvisor), pour empêcher la compilation des fichiers TypeScript dans le répertoire migration du projet parent Strapi, ajoutez le répertoire migration dans la liste d'exclusion de tsconfig.json :

{
  "extends": "@strapi/typescript-utils/tsconfigs/server",
  ...
  "exclude": [
    ...
    "yourMigrationDirectory/"
  ]
}

Initialisation du projet de migration

Créez un nouveau répertoire (par exemple foodadvisor-migration), allez dans ce répertoire et exécutez :

npm init -y

Ajoutez les dépendances suivantes (respectivement pour le script de génération de types TypeScript à partir des types de contenu Strapi et pour une API client Strapi) :

npm i @smile/strapi-content-type-to-ts @smile/strapi-client

Ensuite, créez un fichier tsconfig.json car nous allons coder en modules TypeScript :

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "skipLibCheck": true
  }
}

Créez un fichier migrate.mts (nous ajouterons du code plus tard).
Et ajoutez un scripts dans le fichier package.json :

{
  "name": "foodadvisor-migration",
  "scripts": {
    "generate-content-types": "strapi-content-type-to-ts -s ../foodadvisor/api -e custom-field -o strapi-content-types.mts",
    "migrate": "tsc && node migrate.mjs"
  },
  ...
}

Assurez-vous de modifier le paramètre -s dans le script generate-content-types pour correspondre au chemin relatif vers votre répertoire FoodAdvisor api depuis votre projet foodadvisor-migration.

 

Générer les types

Exécutez le script suivant :

npm run generate-content-types

Cela devrait créer un fichier strapi-content-types.mts avec toutes les interfaces correspondant aux types de contenu Strapi.
Mais il a également logué un avertissement sur le fait que le type de champ ckeditor.CKEditor n'était pas géré :

Missing custom field plugin for ckeditor.CKEditor.
Create a /.../foodadvisor-migration/custom-field/ckeditor.CKEditor.js file with the following signature:
module.exports = function (options) {
  return '...';
}

En effet, par défaut, le script ne gère que les types de champs natifs Strapi. Pour les types non gérés, il génère un type TypeScript any :

export interface Article {
  title: string;
  ...
  ckeditor_content: any //FIXME: missing custom field plugin for ckeditor.CKEditor;
}

Mais ckeditor.CKEditor n'est qu'une belle interface utilisateur au-dessus d'un type de contenu de type chaîne. Nous pourrions donc ajouter un plugin à @smile/strapi-content-type-to-ts en suivant les indications du log d’avertissement.
Créez un fichier custom-field/ckeditor.CKEditor.js avec le contenu suivant :

module.exports = function (options) {
  return 'string';
}

Exécutez à nouveau le script (npm run generate-content-types) et vous verrez qu'il n'y a plus d'avertissement et que le type a été remplacé de "any" à "string" dans l'interface générée.

export interface Article {
  title: string;
  ...
  ckeditor_content: string;
}

Dans notre démo, nous n'importerons que des restaurants. Voici la version (simplifiée) des interfaces que nous utiliserons :

export interface Restaurant {
  name: string;
  slug?: string;
  images: { id: number }[];
  price: (`p1` | `p2` | `p3` | `p4`);
  information?: RestaurantInformation;
  place?: number;
}

export interface RestaurantInformation {
  location?: RestaurantLocation;
}

export interface RestaurantLocation {
  address?: string;
}

Maintenant que nous disposons de types de contenu Strapi, préparons nos données d'entrée source.

 

Préparer les données d'entrée de la source

Pour notre démo, les données à migrer proviendront d'un fichier JSON, restaurants.json, avec le format suivant :

[
  {
    "name": "Epicure - Le Bristol Paris",
    "slug": "epicure-le-bristol-paris",
    "mainPhotoSrc": "https://res.cloudinary.com/tf-lab/image/upload/restaurant/8c5df4b2-34be-4eba-bc36-e08e73665325/c171c70e-8b70-4d83-a9ed-dfe8643da943.jpg",
    "priceRange": 451,
    "address": {
      "street": "112 Rue du Faubourg Saint-Honoré",
      "postalCode": "75008",
      "locality": "Paris",
      "country": "France"
    }
  },
  ...
]

Créez le fichier restaurants.json et remplissez-le de données. Vous pouvez utiliser ce JSON.
Pour typer ces données, créons un fichier input-types.mts décrivant le format des données d'entrée :

export interface RestaurantInput {
    name: string;
    slug: string;
    mainPhotoSrc?: string;
    priceRange: number;
    address?: {
        street?: string;
        postalCode?: string;
        locality?: string;
        country?: string;
    }
}

Nous devrons également transformer ces données sources. Pour cela, créons un fichier utils.mts :

import {RestaurantInput} from './input-types.mjs';
import {Restaurant} from './strapi-content-types.mjs';

/**
 * Returns the Strapi content type (among ‘p1’, ‘p2’, ‘p3’ and ‘p4’) from a numeric price
 */
export function getPrice(price: number): NonNullable<Restaurant['price']> {
    return price > 200
        ? 'p4'
        : price > 100
            ? 'p3'
            : price > 50
                ? 'p2'
                : 'p1';
}

/**
 * Returns a String address from a structured object address
 */
export function getAddress(restaurant: RestaurantInput): string {
    return [
        restaurant.address?.street,
        restaurant.address?.postalCode,
        restaurant.address?.locality,
        restaurant.address?.country,
    ]
        .filter(v => !!v)
        .join(' ');
}

Les données d'entrée de la source fournissent un prix numérique. Nous devons déduire le prix (parmi 'p1', 'p2', 'p3' et 'p4') à partir de cette valeur numérique. La fonction getPrice effectue cette correspondance en utilisant la logique suivante : p1 ≤ 50 < p2 ≤ 100 < p3 ≤ 200 < p4.
Les données d'entrée de la source fournissent une adresse sous forme d'objet structuré avec les éléments suivants : rue, code postal, localité et pays. Cependant, notre type de contenu cible Strapi ne dispose que d'une adresse sous forme de chaîne de caractères (dans information.location.address). La fonction getAddress extrait cette adresse sous forme de chaîne de caractères à partir d'un objet d'entrée de restaurant.
Nous avons maintenant tout ce dont nous avons besoin pour écrire le script de migration.

 

Rédiger le script de migration

Nous devons d'abord obtenir les jetons d'API requis. Assurez-vous que Strapi fonctionne et connectez-vous avec un compte administrateur :

  • générez un jeton dans Paramètres > Jetons d’API :
    • cliquez sur "Ajouter une entrée"
    • saisissez un nom, choisissez une durée "Illimité" et un type de jeton "Accès total".
    • cliquez sur "Enregistrer"
    • copier le jeton
  • Obtenez votre jeton JWT d'administrateur :
    • ouvrez les outils de développement de votre navigateur (F12 ou CTRL+SHIFT+i)
    • exécutez dans votre console la commande suivante :
JSON.parse(sessionStorage.jwtToken || localStorage.jwtToken)
  • copier le jeton

Ouvrez le fichier migrate.mts et créez un StrapiClient (remplissez les jetons récupérés à l'étape précédente) :

import {StrapiClient} from '@smile/strapi-client';

const token = '...';
const adminToken = '...';
const strapiClient = new StrapiClient('http://127.0.0.1:1337', token, adminToken);

Continuer à éditer le fichier migrate.mts pour charger les restaurants à partir du fichier JSON :

import {StrapiClient} from '@smile/strapi-client';
import * as fs from 'fs';
import {RestaurantInput} from './input-types.mjs';

...

const restaurants: RestaurantInput[] = JSON.parse(fs.readFileSync('./restaurants.json').toString());

Ensuite, écrivez une fonction créant un restaurant et l'image correspondante :

import {StrapiClient} from '@smile/strapi-client';
import * as fs from 'fs';
import {RestaurantInput} from './input-types.mjs';
import {Restaurant} from './strapi-content-types.mjs';
import {getAddress, getPrice} from './utils.mjs';

...

const mediaIds: number[] = [];

async function createRestaurant(restaurant: RestaurantInput) {
  const mediaCreationResponse = await strapiClient.addMediaAsset(restaurant.mainPhotoSrc!);
  const mediaId = mediaCreationResponse[0].id;
  mediaIds.push(mediaId);
  await strapiClient.createEntry<Restaurant>('restaurants', {
    name: restaurant.name,
    slug: restaurant.slug,
    images: [{id: mediaId}],
    price: getPrice(restaurant.priceRange),
    information: {
      location: {
        address: getAddress(restaurant)
      }
    },
    place: 1
  });
}

N.B. :

  • nous créons un mediaIds global pour stocker tous les identifiants des médias créés. Le but est de pouvoir les déplacer dans un seul répertoire plus tard
  • nous créons d'abord le média avant de créer le restaurant afin d'avoir l'identifiant du média pour l'attribut images
  • le média est automatiquement téléchargé par la fonction strapiClient.addMediaAsset
  • nous utilisons getPrice et getAddress de notre fichier utils.mts
  • nous attribuons la valeur 1 au lieu, qui doit correspondre au lieu existant "Paris" qui a été créé par la seed.


Nous devons maintenant boucler sur les restaurants et appeler la fonction createRestaurant :

...

for (let i = 0; i < restaurants.length; i++){
  const restaurant = restaurants[i];
  try {
    await createRestaurant(restaurant);
    console.log(`Created restaurant ${i + 1}/${restaurants.length} (${Math.round((i + 1) * 100 / restaurants.length)}%)`);
  } catch (e) {
    console.error(`Failed creating restaurant`, JSON.stringify(restaurant, null, 2), e);
  }
}

N.B. : nous ajoutons quelques logs pour détecter les erreurs potentielles et visualiser la progression.

Pour terminer, nous créons un répertoire de médias et y déplaçons tous les médias importés :

...

const mediaFolderCreation = await strapiClient.createMediaFolder(`Migration ${new Date().toISOString()}`);
await strapiClient.moveMedia(mediaFolderCreation.data.id, mediaIds);

Exécutez le script :

npm run migrate

Vous devriez voir les logs suivants :

Created restaurant 1/20 (5%)
Created restaurant 2/20 (10%)
...
Created restaurant 19/20 (95%)
Created restaurant 20/20 (100%)

Et vous devriez voir vos restaurants dans l'interface d'administration de Strapi (et même dans l'application web Next si vous l'avez lancée).

 

Sources

Vous pouvez trouver les sources complètes de cette démo ici.

Considérations de performance

Certaines bonnes pratiques peuvent être suivies pour améliorer les performances. Grâce à ces bonnes pratiques, sur un projet réel de migration de données, nous avons importé avec succès environ 37 000 contenus et 5 500 images (2,5 Go) en moins de 20 minutes sur un ordinateur portable de développement.
Voici quelques-unes de nos recommandations.

 

Utiliser une vraie base de données

Utilisez une vraie base de données (comme PostgreSQL) au lieu de SQLite. SQLite a un impact réel sur les performances.

 

Pré-télécharger les médias

Si vous devez importer des médias à partir d'une URL, créez un script pour les télécharger localement, puis faites en sorte que votre script de migration utilise la version locale de ces médias. Lors de l'exécution répétée de l'import dans le cadre du développement, les performances s'en trouveront grandement améliorées, car vous n'aurez à télécharger les médias qu'une seule fois.

 

Configurer les images réactives

L'import d'une image est assez longue en raison de la fonction Strapi responsive image qui crée plusieurs tailles d'image. Assurez-vous qu'elle est configurée avec les tailles dont vous avez besoin et désactivez la fonction si vous n'en avez pas besoin.

 

Paralléliser les appels à l'API

Pour tirer le meilleur parti des processeurs multicœurs, vous devez paralléliser vos appels d'API. Cela améliorera considérablement les performances de la migration. Le nombre d'appels parallèles à configurer est empirique (et vous devriez tester différentes valeurs pour choisir la meilleure dans votre cas), mais une valeur par défaut de 20 devrait convenir.
Voici un exemple de fonction qui peut être utilisée pour gérer plusieurs appels d'API en parallèle :

async function batchTasks<T>(items: T[], task: (context: {
  item: T,
  items: T[],
  index: number,
  size: number
}) => Promise<any>, limit: number) {
  const activeTasks: (Promise<any>)[] = [];
  const tasks = items.map((item, index) => () => task({item, items, index, size: items.length}));
  for (const task of tasks) {
    if (activeTasks.length >= limit) {
      await Promise.race(activeTasks);
    }
    const activeTask = task().finally(() => activeTasks.splice(activeTasks.indexOf(activeTask), 1));
    activeTasks.push(activeTask);
  }
  return Promise.all(activeTasks);
}

Il pourrait être utilisé de cette manière pour importer 10 restaurants en parallèle :

await batchTasks(
  restaurants,
  async ({item: restaurant, index, size}) => {
    try {
      await createRestaurant(restaurant);
      console.log(`Created restaurant ${index + 1}/${size} (${Math.round((index + 1) * 100 / size)}%)`);
    } catch (e) {
      console.error(`Failed creating restaurant`, JSON.stringify(restaurant, null, 2), e);
    }
  },
  10
);

N.B. : un bogue de concurrence existe actuellement dans Strapi lorsque l'on essaie de télécharger plusieurs médias en parallèle, car Strapi tente de créer un répertoire "API Uploads" dans des threads simultanés en parallèle sans verrou approprié. Vous devrez donc vous assurer que le répertoire "API Uploads" existe avant d'importer plusieurs médias en parallèle, importer les médias de manière séquentielle ou vous assurer que le premier téléchargement est terminé avant de télécharger les autres médias en parallèle afin de garantir que le répertoire "API Uploads" a été créé avant d'importer les autres médias.

 

Conclusion

Rétrospectivement, pour notre projet réel de migration de données, l'utilisation de l'API REST était une bonne approche, car elle répondait à toutes nos attentes en termes de fonctionnalités et de performances. Il est à noter que nous avons réalisé une démonstration de faisabilité pour valider les performances de cette approche avant de développer l'ensemble de la migration.
Cette approche résulte de nos besoins en matière de migration de données pour notre cas d'utilisation. Il ne s'agit pas d'une solution miracle et il se peut qu'elle ne corresponde pas à vos besoins.
Pour une volumétrie plus importante (plusieurs centaines de milliers ou millions de contenus), les performances de l'API REST peuvent ne pas être suffisantes. Si vous êtes dans ce cas, vous devriez faire un POC pour valider ou non l'approche de l'API REST. Si l'utilisation de l'API REST n'est pas assez performante, envisagez d'utiliser des API de niveau inférieur, la fonction d'import de données de Strapi ou l'import directe de la base de données.
Comme cela n'était pas nécessaire pour notre cas d'utilisation, les paquets @smile/strapi-client et @smile/strapi-content-type-to-ts ne gèrent pas actuellement le plugin d'internationalisation de Strapi (qui donne la possibilité d'enregistrer des valeurs différentes pour différentes langues dans certains champs de type de contenu). Si vous avez besoin de cette fonctionnalité pour votre migration, elle pourra facilement être ajoutée, car ces deux paquets sont Open Source. Vous pouvez même l'implémenter vous-même et créer une Pull Request.

Maxime Robert

Maxime Robert

Expert technique