CentraleSupélecDépartement informatique
Plateau de Moulon
3 rue Joliot-Curie
F-91192 Gif-sur-Yvette cedex
Cas d'étude : OpenAPI

Corrigé

L'objectif de ce cas d'étude est d'appliquer des techniques de MDE (métamodélisation, transformations de modèles) à la génération de code pour une application web.

Transparents de présentation : PowerPoint, PDF

Contexte

On souhaite écrire une application web qui utilise les services de Spotify. Sur Stackblitz, vous trouverez un environnement de développement dans lequel tourne l'application Blind-test. Si vous souhaitez voir fonctionner l'application, créez un compte Spotify, likez quelques titres, et rendez-vous sur Spotify for developers. Cliquez sur Try it et récupérez un token dans l'onglet Network de la console de développement. Collez ce token dans Stackblitz.

L'API de Spotify est décrite au format OpenAPI.

Script de génération des types

L'objectif est de générer des fichiers de code en typescript à partir d'un fichier de spécification OpenAPI.

La description du schema de l'API se trouve dans le fichier openapi.json ou openapi.yaml.

Un squelette du script est fourni dans le fichier scripts/generate-spotify-client.js. Pour exécuter le script, il suffit d'ouvrir un terminal et de lancer la commande npm run generate-spotify-client. Le script crée des fichiers dans le dossier src/lib/spotify/model. Le but est de remplir ces fichiers avec les types décrits dans la spécification.

Instructions

Astuce:

  • Faites en sorte que le script fonctionne à tout moment sans erreur, par exemple en écrivant des fonctions qui retournent toujours quelque chose même si tous les cas n'ont pas été traités (if(...) {} else return "";).
  • Ainsi vous pourrez lancer le script à tout moment pour vérifier que les premiers types ont été correctement générés.

Types simples

Écrire une fonction qui prend en entrée un object schema, et qui renvoit le type typescript associé selon la propriété type de l'objet passé en paramètre. Gérer tout d'abord les cas des types number, integer, string et boolean.

Dans la fonction getGeneratedCode (prédéfinie dans le squelette), appeler la fonction précédente et exporter le type correspondant.

Par exemple, dans le fichier openapi.json, on trouve des schema définis ainsi :

"Key": {
    "type": "integer",
    "example": 9,
    "minimum": -1,
    "maximum": 11,
    "description": "The key the track is in. Integers map to pitches using standard [Pitch Class notation](https://en.wikipedia.org/wiki/Pitch_class). E.g. 0 = C, 1 = C♯/D♭, 2 = D, and so on. If no key was detected, the value is -1.\n"
},
"Tempo": {
    "type": "number",
    "example": 118.211,
    "format": "float",
    "x-spotify-docs-type": "Float",
    "description": "The overall estimated tempo of a track in beats per minute (BPM). In musical terminology, tempo is the speed or pace of a given piece and derives directly from the average beat duration.\n"
}

Après avoir exécuté votre première version du script, le fichier src/lib/spotify/model/Tempo.ts devrait contenir :

export type Tempo = number;

Types structurés

Des types plus complexes peuvent contenir plusieurs propriétés, ayant chacun un type différent. Dans la spécification OpenAPI, cela est indiqué par le champs type du schema, dont la valeur est "object", ainsi que par la présence d'un champ properties, dont la valeur est un tableau de schema.

Par exemple, dans le fichier openapi.json, le schema AuthorObject est défini comme suit :

"AuthorObject": {
    "type": "object",
    "x-spotify-docs-type": "AuthorObject",
    "properties": {
        "name": {
            "type": "string",
           "description": "The name of the author.\n"
         }
     }
}

Écrire le code correspondant et vérifier le résultat dans le fichier AuthorObject.ts.

  1. Utiliser la fonction javascript Object.keys(schema) pour itérer sur les clefs d'un objet.

Champs requis

En typescript, un champ peut être marqué comme facultatif en accolant ? à sa définition (comme ceci: text?: string;). Modifier la génération des types structurés pour prendre en compte les champs requis et facultatifs.

Exemple de schema a générer :

"ImageObject": {
    "type": "object",
    "x-spotify-docs-type": "ImageObject",
    "required": [
        "url",
        "height",
        "width"
    ],
    "properties": {
        ...
     }               
}

Import de types

Un objet peut avoir un champ dont le type est lui même défini dans un autre fichier.

Un schema ayant une proprété `$ref` indique que le champ a pour type un autre type défini dans le spécification. Par exemple, dans la définition du schema suivant, le champ key fait référence au type Key qui a déjà été généré. En typescript, cela nécessite d’importer le type depuis un autre fichier, comme ceci : import { Key } from "./Key";.

"AudioFeaturesObject": {
    "type": "object",
    "x-spotify-docs-type": "AudioFeaturesObject",
    "properties": {
        ..., 
        "key": {
            "$ref": "#/components/schemas/Key"
         },
        ...
        "loudness": {
            "$ref": "#/components/schemas/Loudness"
        },
        "mode": {
            "$ref": "#/components/schemas/Mode"
        },
        ...
        "tempo": {
            "$ref": "#/components/schemas/Tempo"
        },
        "time_signature": {
            "$ref": "#/components/schemas/TimeSignature"
        },
        ...
    }
}

Après avoir exécuté le script, le contenu du fichier AudioFeaturesObject.ts devrait ressembler à :

import { Key } from "./Key";
import { Loudness } from "./Loudness";
import { Mode } from "./Mode";
import { Tempo } from "./Tempo";
import { TimeSignature } from "./TimeSignature";

export type AudioFeaturesObject = {
  acousticness?: number;
  analysis_url?: string;
  danceability?: number;
  duration_ms?: number;
  energy?: number;
  id?: string;
  instrumentalness?: number;
  key?: Key;
  liveness?: number;
  loudness?: Loudness;
  mode?: Mode;
  speechiness?: number;
  tempo?: Tempo;
  time_signature?: TimeSignature;
  track_href?: string;
  type?: "audio_features";
  uri?: string;
  valence?: number;
};
  1. Pour écrire tous les imports nécessaires au début du fichier, il faut les stocker dans une variable dans laquelle on ajoute au fur et à mesure tous les types à importer. Il faut passer cette variable en argument de toutes les fonctions qui peuvent avoir besoin de la mettre à jour.

Tableaux

Un champ peut être un tableau d'objets ayant tous le même type. En typescript, un tableau de string s’écrit string[]. Dans la spécification OpenAPI, un schema dont le type est array possède également un champ items indiquant le schema des éléments du tableau.

Par exemple :

"AlbumBase": {
    "properties": {              
        ...
        "available_markets": {
            "type": "array",
            "items": {
                "type": "string"
             },
             "example": [
                 "CA",
                 "BR",
                 "IT"
             ],
              "description": "The markets in which the album is available: [ISO 3166-1 alpha-2 country codes](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). _**NOTE**: an album is considered available in a market when at least 1 of its tracks is available in that market._\n"
        },
        ...
    }
}

Union de types

Un type peut être une union de types. Un telchamp peut avoir indifféremment un type ou l'autre. Par exemple, en typescript, un champ de type string | number peut être une chaîne de caractères ou un nombre. Dans la spécification OpenAPI, un schema peut avoir une propriété oneOf afin d’indiquer qu’il peut suivre indistinctement l’un des schema spécifiés. Par exemple :

"QueueObject": {
  "type": "object",
  "x-spotify-docs-type": "QueueObject",
  "properties": {
    "currently_playing": {
      "oneOf": [
          {
          "$ref": "#/components/schemas/TrackObject"
          },
          {
          "$ref": "#/components/schemas/EpisodeObject"
          }
        ],
      "discriminator": {
        "propertyName": "type"
        },
      "x-spotify-docs-type": "TrackObject | EpisodeObject",
      "description": "The currently playing track or episode. Can be `null`."
      },
    "queue": {
      "type": "array",
      "items": {
        "oneOf": [
            {
            "$ref": "#/components/schemas/TrackObject"
            },
            {
            "$ref": "#/components/schemas/EpisodeObject"
            }
          ],
        "discriminator": {
          "propertyName": "type"
          },
        "x-spotify-docs-type": "TrackObject | EpisodeObject"
        },
      "description": "The tracks or episodes in the queue. Can be empty."
    }
  }
}

Attention à l’interaction entre les types allOf et array : les types `(TrackObject | EpisodeObject)[]` et TrackObject | EpisodeObject[]` ne sont pas équivalents !

Intersection de types

En typescript, un type peut combiner toutes les propriétés de plusieurs autres types. Le type AudiobookBase & AlbumBase possède à la fois les propriétés de AudiobookBase et de AlbumBase. Dans la spécification OpenAPI, un tel type est indiqué par une propriété propriété allOf dans le schema. Par exemple :

"AudiobookObject": {
  "x-spotify-docs-type": "AudiobookObject",
  "allOf": [
    {
      "$ref": "#/components/schemas/AudiobookBase"
    },
    {
      "type": "object",
      "required": ["chapters"],
      "properties": {
        "chapters": {
          "type": "object",
          "allOf": [
            {
              "$ref": "#/components/schemas/PagingSimplifiedChapterObject"
            }
          ],
          "description": "The chapters of the audiobook.\n"
        }
      }
    }
  ]
}

Énumérations

Un champ de type string peut parfois parfois spécifier un nombre fini de valeurs qui peuvent être prises par le champ. Celles-ci sont spécifiées dans la propriété enum du schema. En typescript, une enumération de string peut s’écrire simplement "value1" | "value2"

"AlbumRestrictionObject": {
  "type": "object",
  "x-spotify-docs-type": "AlbumRestrictionObject",
  "properties": {
    "reason": {
      "type": "string",
      "enum": ["market", "product", "explicit"],
      "description": "The reason for the restriction. Albums may be restricted if the content is not available in a given market, to the user's subscription type, or when the user's account is set to not play explicit content.\nAdditional reasons may be added in the future.\n"
    }
  }
}

Validation

Parcourir tous les fichiers générés pour s'assurer qu'ils ne contiennent pas d'erreurs, qu'ils sont bien formattés et qu'aucun cas limite n'a été oublié.

Transformation du modèle à l'aide d'une librairie

En réalité, de nombreux outils sont déjà disponibles en open source pour réaliser diverses transformations de modèle à partir d'un fichier de spécification OpenAPI, et générer du code pour différents frameworks de programmation web, par exemple Orval.

Pour faire fonctionner la librairie, on dispose d'un fichier de configuration orval.config.ts dans lequel on spécifie différents paramètres, comme le dossier cible, le type de client à générer, et le fichier d'input.

Dans une console de commande, lancer la commande npx orval et observer le code générer.

Par exemple, dans le fichier src/lib/spotify/api/tracks/tracks.ts, voir la fonction getUsersSavedTracksParams, son type de retour et ses arguments. Essayer d'utiliser cette fonction générée à la place de l'appel à fetchTracks dans App.tsx.

  1. Comme on peut le voir en observant la signature de la fonction, l'argument options (de type AxiosRequestConfig) est un objet qui possède un champ headers, qui contient les headers de la requête HTTP. Il faut passer un objet semblable à celui donné en arguments de fetchTracks pour envoyer le token.
  2. En pratique, on configurerait plutôt un système d'interception des requêtes pour paramétrer l'authentification plutôt que de spécifier le token à la main à chaque appel de fonction.