ActiveResource.js : création rapide d'un SDK JavaScript puissant pour votre API JSON

Publié: 2022-03-11

Votre entreprise vient de lancer son API et souhaite désormais constituer une communauté d'utilisateurs autour d'elle. Vous savez que la plupart de vos clients travailleront en JavaScript, car les services fournis par votre API permettent aux clients de créer plus facilement des applications Web au lieu de tout écrire eux-mêmes. Twilio en est un bon exemple.

Vous savez également que, aussi simple que puisse être votre API RESTful, les utilisateurs voudront déposer un package JavaScript qui fera tout le gros du travail pour eux. Ils ne voudront pas apprendre votre API et créer eux-mêmes chaque requête dont ils ont besoin.

Vous construisez donc une bibliothèque autour de votre API. Ou peut-être écrivez-vous simplement un système de gestion d'état pour une application Web qui interagit avec votre propre API interne.

Quoi qu'il en soit, vous ne voulez pas vous répéter encore et encore chaque fois que vous CRUDez l'une de vos ressources API, ou pire, CRUDez une ressource liée à ces ressources. Ce n'est pas bon pour gérer un SDK en pleine croissance sur le long terme, ni une bonne utilisation de votre temps.

Au lieu de cela, vous pouvez utiliser ActiveResource.js, un système JavaScript ORM pour interagir avec les API. Je l'ai créé pour combler un besoin que nous avions sur un projet : créer un SDK JavaScript en aussi peu de lignes que possible. Cela a permis une efficacité maximale pour nous et pour notre communauté de développeurs.

Il est basé sur les principes sous-jacents à l'ORM ActiveRecord simple de Ruby on Rails.

Principes du SDK JavaScript

Deux idées Ruby on Rails ont guidé la conception d'ActiveResource.js :

  1. « Convention plutôt que configuration » : faites des hypothèses sur la nature des points de terminaison de l'API. Par exemple, si vous avez une ressource Product , elle correspond au point de terminaison /products . De cette façon, le temps n'est pas passé à configurer à plusieurs reprises chacune des demandes de votre SDK à votre API. Les développeurs peuvent ajouter de nouvelles ressources API avec des requêtes CRUD complexes à votre SDK en pleine croissance en quelques minutes, et non en quelques heures.
  2. "Exalt beautiful code" : le créateur de Rails, DHH, l'a dit le mieux : il y a juste quelque chose de génial dans le beau code pour lui-même. ActiveResource.js enveloppe des requêtes parfois laides dans un bel extérieur. Vous n'avez plus besoin d'écrire de code personnalisé pour ajouter des filtres et une pagination et inclure des relations imbriquées dans des relations avec des requêtes GET. Vous n'avez pas non plus besoin de construire des requêtes POST et PATCH qui modifient les propriétés d'un objet et les envoient au serveur pour mise à jour. Au lieu de cela, appelez simplement une méthode sur une ActiveResource : plus besoin de jouer avec JSON pour obtenir la requête que vous voulez, seulement pour avoir à le refaire pour la suivante.

Avant de commencer

Il est important de noter qu'au moment d'écrire ces lignes, ActiveResource.js ne fonctionne qu'avec des API écrites selon la norme JSON:API.

Si vous n'êtes pas familier avec JSON:API et que vous souhaitez suivre, il existe de nombreuses bonnes bibliothèques pour créer un serveur JSON:API.

Cela dit, ActiveResource.js est plus un DSL qu'un wrapper pour une norme API particulière. L'interface qu'il utilise pour interagir avec votre API peut être étendue, de sorte que les futurs articles pourraient expliquer comment utiliser ActiveResource.js avec votre API personnalisée.

Mettre les choses en place

Pour commencer, installez active-resource dans votre projet :

 yarn add active-resource

La première étape consiste à créer une ResourceLibrary pour votre API. Je vais mettre tous mes ActiveResource s dans le dossier src/resources :

 // /src/resources/library.js import { createResourceLibrary } from 'active-resource'; const library = createResourceLibrary('http://example.com/api/v1'); export default library;

Le seul paramètre requis pour createResourceLibrary est l'URL racine de votre API.

Ce que nous allons créer

Nous allons créer une bibliothèque JavaScript SDK pour une API de système de gestion de contenu. Cela signifie qu'il y aura des utilisateurs, des publications, des commentaires et des notifications.

Les utilisateurs pourront lire, créer et modifier des publications ; lire, ajouter et supprimer des commentaires (aux messages ou à d'autres commentaires) et recevoir des notifications de nouveaux messages et commentaires.

Je ne vais pas utiliser de bibliothèque spécifique pour gérer la vue (React, Angular, etc.) ou l'état (Redux, etc.), mais plutôt résumer le tutoriel pour interagir uniquement avec votre API via ActiveResource s.

La première ressource : les utilisateurs

Nous allons commencer par créer une ressource User pour gérer les utilisateurs du CMS.

Tout d'abord, nous créons une classe de ressource User avec quelques attributes :

 // /src/resources/User.js import library from './library'; class User extends library.Base { static define() { this.attributes('email', 'userName', 'admin'); } } export default library.createResource(User);

Supposons pour l'instant que vous disposiez d'un point de terminaison d'authentification qui, une fois qu'un utilisateur a soumis son adresse e-mail et son mot de passe, renvoie un jeton d'accès et l'ID de l'utilisateur. Ce point de terminaison est géré par une fonction requestToken . Une fois que vous avez obtenu l'ID utilisateur authentifié, vous souhaitez charger toutes les données de l'utilisateur :

 import library from '/src/resources/library'; import User from '/src/resources/User'; async function authenticate(email, password) { let [accessToken, userId] = requestToken(email, password); library.headers = { Authorization: 'Bearer ' + accessToken }; return await User.find(userId); }

J'ai défini library.headers pour avoir un en-tête Authorization avec accessToken afin que toutes les demandes futures de ma ResourceLibrary soient autorisées.

Une section ultérieure expliquera comment authentifier un utilisateur et définir le jeton d'accès en utilisant uniquement la classe de ressources User .

La dernière étape de l' authenticate est une demande à User.find(id) . Cela enverra une requête à /api/v1/users/:id , et la réponse pourrait ressembler à :

 { "data": { "type": "users", "id": "1", "attributes": { "email": "[email protected]", "user_name": "user1", "admin": false } } }

La réponse de l' authenticate sera une instance de la classe User . De là, vous pouvez accéder aux différents attributs de l'utilisateur authentifié, si vous souhaitez les afficher quelque part dans l'application.

 let user = authenticate(email, password); console.log(user.id) // '1' console.log(user.userName) // user1 console.log(user.email) // [email protected] console.log(user.attributes()) /* { email: '[email protected]', userName: 'user1', admin: false } */

Chacun des noms d'attribut deviendra camelCased, pour s'adapter aux normes typiques de JavaScript. Vous pouvez obtenir chacun d'eux directement en tant que propriétés de l'objet user ou obtenir tous les attributs en appelant user.attributes() .

Ajout d'un index de ressources

Avant d'ajouter d'autres ressources liées à la classe User , telles que les notifications, nous devons ajouter un fichier, src/resources/index.js , qui indexera toutes nos ressources. Cela a deux avantages :

  1. Cela nettoiera nos importations en nous permettant de déstructurer src/resources pour plusieurs ressources dans une instruction d'importation au lieu d'utiliser plusieurs instructions d'importation.
  2. Il initialisera toutes les ressources sur la ResourceLibrary que nous allons créer en appelant library.createResource sur chacune, ce qui est nécessaire pour qu'ActiveResource.js établisse des relations.
 // /src/resources/index.js import User from './User'; export { User };

Ajout d'une ressource associée

Créons maintenant une ressource associée pour l' User , une Notification . Créez d'abord une classe Notification qui belongsTo à la classe User :

 // /src/resources/Notification.js import library from './library'; class Notification extends library.Base { static define() { this.belongsTo('user'); } } export default library.createResource(Notification);

Ensuite, nous l'ajoutons à l'index des ressources :

 // /src/resources/index.js import Notification from './Notification'; import User from './User'; export { Notification, User };

Reliez ensuite les notifications à la classe User :

 // /src/resources/User.js class User extends library.Base { static define() { /* ... */ this.hasMany('notifications'); } }

Maintenant, une fois que nous avons récupéré l'utilisateur de l' authenticate , nous pouvons charger et afficher toutes ses notifications :

 let notifications = await user.notifications().load(); console.log(notifications.map(notification => notification.message));

Nous pouvons également inclure des notifications dans notre demande d'origine pour l'utilisateur authentifié :

 async function authenticate(email, password) { /* ... */ return await User.includes('notifications').find(userId); }

C'est l'une des nombreuses options disponibles dans le DSL.

Examen de la DSL

Couvrons ce qu'il est déjà possible de demander uniquement à partir du code que nous avons écrit jusqu'à présent.

Vous pouvez interroger un ensemble d'utilisateurs ou un seul utilisateur.

 let users = await User.all(); let user = await User.first(); user = await User.last(); user = await User.find('1'); user = await User.findBy({ userName: 'user1' });

Vous pouvez modifier la requête à l'aide de méthodes relationnelles chaînées :

 // Query and iterate over all users User.each((user) => console.log(user)); // Include related resources let users = await User.includes('notifications').all(); // Only respond with user emails as the attributes users = await User.select('email').all(); // Order users by attribute users = await User.order({ email: 'desc' }).all(); // Paginate users let usersPage = await User.page(2).perPage(5).all(); // Filter users by attribute users = await User.where({ admin: true }).all(); users = await User .includes('notifications') .select('email', { notifications: ['message', 'createdAt'] }) .order({ email: 'desc' }) .where({ admin: false }) .perPage(10) .page(3) .all(); let user = await User .includes('notification') .select('email') .first();

Notez que vous pouvez composer la requête en utilisant n'importe quelle quantité de modificateurs chaînés et que vous pouvez terminer la requête avec .all() , .first() , .last() ou .each() .

Vous pouvez créer un utilisateur localement ou en créer un sur le serveur :

 let user = User.build(attributes); user = await User.create(attributes);

Une fois que vous avez un utilisateur persistant, vous pouvez lui envoyer des modifications pour qu'elles soient enregistrées sur le serveur :

 user.email = '[email protected]'; await user.save(); /* or */ await user.update({ email: '[email protected]' });

Vous pouvez également le supprimer du serveur :

 await user.destroy();

Ce DSL de base s'étend également aux ressources associées, comme je le démontrerai dans le reste du didacticiel. Maintenant, nous pouvons rapidement appliquer ActiveResource.js à la création du reste du CMS : publications et commentaires.

Création de messages

Créez une classe de ressources pour Post et associez-la à la classe User :

 // /src/resources/Post.js import library from './library'; class Post extends library.Base { static define() { this.belongsTo('user'); } } export default library.createResource(Post);
 // /src/resources/User.js class User extends library.Base { static define() { /* ... */ this.hasMany('notifications'); this.hasMany('posts'); } }

Ajoutez également Post à l'index des ressources :

 // /src/resources/index.js import Notification from './Notification'; import Post from './Post'; import User from './User'; export { Notification, Post, User };

Associez ensuite la ressource Post à un formulaire permettant aux utilisateurs de créer et de modifier des publications. Lorsque l'utilisateur visite pour la première fois le formulaire de création d'une nouvelle publication, une ressource Post sera créée, et chaque fois que le formulaire est modifié, nous appliquons la modification à la Post :

 import Post from '/src/resources/Post'; let post = Post.build({ user: authenticatedUser }); onChange = (event) => { post.content = event.target.value; };

Ensuite, ajoutez un rappel onSubmit au formulaire pour enregistrer la publication sur le serveur et gérez les erreurs si la tentative d'enregistrement échoue :

 onSubmit = async () => { try { await post.save(); /* successful, redirect to edit post form */ } catch { post.errors().each((field, error) => { console.log(field, error.message) }); } }

Modification des messages

Une fois la publication enregistrée, elle sera liée à votre API en tant que ressource sur votre serveur. Vous pouvez savoir si une ressource est persistante sur le serveur en appelant persisted :

 if (post.persisted()) { /* post is on server */ }

Pour les ressources persistantes, ActiveResource.js prend en charge les attributs modifiés, en ce sens que vous pouvez vérifier si un attribut d'une ressource a été modifié par rapport à sa valeur sur le serveur.

Si vous appelez save() sur une ressource persistante, il effectuera une requête PATCH contenant uniquement les modifications apportées à la ressource, au lieu de soumettre inutilement l'ensemble complet d'attributs et de relations de la ressource au serveur.

Vous pouvez ajouter des attributs suivis à une ressource à l'aide de la déclaration d' attributes . Suivons les modifications apportées à post.content :

 // /src/resources/Post.js class Post extends library.Base { static define() { this.attributes('content'); /* ... */ } }

Maintenant, avec une publication persistante sur le serveur, nous pouvons modifier la publication et, lorsque vous cliquez sur le bouton Soumettre, enregistrez les modifications sur le serveur. Nous pouvons également désactiver le bouton d'envoi si aucune modification n'a encore été apportée :

 onEdit = (event) => { post.content = event.target.value; } onSubmit = async () => { try { await post.save(); } catch { /* display edit errors */ } } disableSubmitButton = () => { return !post.changed(); }

Il existe des méthodes pour gérer une relation singulière comme post.user() , si on voulait changer l'utilisateur associé à une publication :

 await post.updateUser(user);

Cela équivaut à :

 await post.update({ user });

La ressource de commentaire

Créez maintenant une classe de ressources Comment et associez-la à Post . Rappelez-vous notre exigence selon laquelle les commentaires peuvent être en réponse à une publication ou à un autre commentaire, de sorte que la ressource pertinente pour un commentaire est polymorphe :

 // /src/resources/Comment.js import library from './library'; class Comment extends library.Base { static define() { this.attributes('content'); this.belongsTo('resource', { polymorphic: true, inverseOf: 'replies' }); this.belongsTo('user'); this.hasMany('replies', { as: 'resource', className: 'Comment', inverseOf: 'resource' }); } } export default library.createResource(Comment);

Assurez-vous d'ajouter un Comment à /src/resources/index.js également.

Nous devrons également ajouter une ligne à la classe Post :

 // /src/resources/Post.js class Post extends library.Base { static define() { /* ... */ this.hasMany('replies', { as: 'resource', className: 'Comment', inverseOf: 'resource' }); } }

L'option inverseOf transmise à la définition hasMany pour les replies indique que cette relation est l'inverse de la définition polymorphe belongsTo pour resource . La propriété inverseOf des relations est fréquemment utilisée lors d'opérations entre relations. En règle générale, cette propriété sera automatiquement déterminée via le nom de la classe, mais comme les relations polymorphes peuvent être l'une de plusieurs classes, vous devez définir vous-même l'option inverseOf pour que les relations polymorphes aient toutes les mêmes fonctionnalités que les relations normales.

Gestion des commentaires sur les publications

Le même DSL qui s'applique aux ressources s'applique également à la gestion des ressources associées. Maintenant que nous avons configuré les relations entre les publications et les commentaires, il existe plusieurs façons de gérer cette relation.

Vous pouvez ajouter un nouveau commentaire à un post :

 onSubmitComment = async (event) => { let comment = await post.replies().create({ content: event.target.value, user: user }); }

Vous pouvez ajouter une réponse à un commentaire :

 onSubmitReply = async (event) => { let reply = await comment.replies().create({ content: event.target.value, user: user }); }

Vous pouvez modifier un commentaire :

 onEditComment = async (event) => { await comment.update({ content: event.target.value }); }

Vous pouvez supprimer un commentaire d'un post :

 onDeleteComment = async (comment) => { await post.replies().delete(comment); }

Affichage des publications et des commentaires

Le SDK peut être utilisé pour afficher une liste paginée d'articles, et lorsqu'un article est cliqué, l'article est chargé sur une nouvelle page avec tous ses commentaires :

 import { Post } from '/src/resources'; let postsPage = await Post .order({ createdAt: 'desc' }) .select('content') .perPage(10) .all();

La requête ci-dessus récupérera les 10 publications les plus récentes, et pour optimiser, le seul attribut chargé est leur content .

Si un utilisateur clique sur un bouton pour accéder à la page suivante de publications, un gestionnaire de modifications récupère la page suivante. Ici, nous désactivons également le bouton s'il n'y a pas de pages suivantes.

 onClickNextPage = async () => { postsPage = await postsPage.nextPage(); if (!postsPage.hasNextPage()) { /* disable next page button */ } };

Lorsqu'un lien vers un article est cliqué, nous ouvrons une nouvelle page en chargeant et en affichant l'article avec toutes ses données, y compris ses commentaires, appelés réponses, ainsi que les réponses à ces réponses :

 import { Post } from '/src/resources'; onClick = async (postId) => { let post = await Post.includes({ replies: 'replies' }).find(postId); console.log(post.content, post.createdAt); post.replies().target().each(comment => { console.log( comment.content, comment.replies.target().map(reply => reply.content).toArray() ); }); }

L'appel .target() sur une relation hasMany telle que post.replies() renverra une ActiveResource.Collection de commentaires qui ont été chargés et stockés localement.

Cette distinction est importante, car post.replies().target().first() renverra le premier commentaire chargé. En revanche, post.replies().first() renverra une promesse pour un commentaire demandé à GET /api/v1/posts/:id/replies .

Vous pouvez également demander les réponses à une publication séparément de la demande de publication elle-même, ce qui vous permet de modifier votre requête. Vous pouvez chaîner des modificateurs tels que order , select , includes , where , perPage , page lors de l'interrogation des relations hasMany , tout comme vous le pouvez lors de l'interrogation des ressources elles-mêmes.

 import { Post } from '/src/resources'; onClick = async (postId) => { let post = await Post.find(postId); let userComments = await post.replies().where({ user: user }).perPage(3).all(); console.log('Your comments:', userComments.map(comment => comment.content).toArray()); }

Modification des ressources après leur demande

Parfois, vous souhaitez prendre les données du serveur et les modifier avant de les utiliser. Par exemple, vous pouvez envelopper post.createdAt dans un objet moment() afin de pouvoir afficher une date/heure conviviale pour l'utilisateur à propos de la date de création de la publication :

 // /src/resources/Post.js import moment from 'moment'; class Post extends library.Base { static define() { /* ... */ this.afterRequest(function() { this.createdAt = moment(this.createdAt); }); } }

Immutabilité

Si vous travaillez avec un système de gestion d'état qui favorise les objets immuables, tout le comportement dans ActiveResource.js peut être rendu immuable en configurant la bibliothèque de ressources :

 // /src/resources/library.js import { createResourceLibrary } from 'active-resource'; const library = createResourceLibrary( 'http://example.com/api/v1', { immutable: true } ); export default library;

Revenir en arrière : lier le système d'authentification

Pour conclure, je vais vous montrer comment intégrer votre système d'authentification des utilisateurs dans votre User ActiveResource .

Déplacez votre système d'authentification par jeton vers le point de terminaison API /api/v1/tokens . Lorsque l'e-mail et le mot de passe d'un utilisateur sont envoyés à ce point de terminaison, les données de l'utilisateur authentifié ainsi que le jeton d'autorisation seront envoyés en réponse.

Créez une classe de ressources Token qui appartient à User :

 // /src/resources/Token.js import library from './library'; class Token extends library.Base { static define() { this.belongsTo('user'); } } export default library.createResource(Token);

Ajoutez un Token à /src/resources/index.js .

Ensuite, ajoutez une méthode statique d' authenticate à votre classe de ressources User et associez User à Token :

 // /src/resources/User.js import library from './library'; import Token from './Token'; class User { static define() { /* ... */ this.hasOne('token'); } static async authenticate(email, password) { let user = this.includes('token').build({ email, password }); let authUser = await this.interface().post(Token.links().related, user); let token = authUser.token(); library.headers = { Authorization: 'Bearer ' + token.id }; return authUser; } }

Cette méthode utilise resourceLibrary.interface() , qui dans ce cas est l'interface JSON:API, pour envoyer un utilisateur à /api/v1/tokens . Ceci est valide : un point de terminaison dans JSON:API n'exige pas que les seuls types publiés vers et depuis celui-ci soient ceux dont il porte le nom. La requête sera donc :

 { "data": { "type": "users", "attributes": { "email": "[email protected]", "password": "password" } } }

La réponse sera l'utilisateur authentifié avec le jeton d'authentification inclus :

 { "data": { "type": "users", "id": "1", "attributes": { "email": "[email protected]", "user_name": "user1", "admin": false }, "relationships": { "token": { "data": { "type": "tokens", "id": "Qcg6yI1a5qCxXgKWtSAbZ2MIHFChHAq0Vc1Lo4TX", } } } }, "included": [{ "type": "tokens", "id": "Qcg6yI1a5qCxXgKWtSAbZ2MIHFChHAq0Vc1Lo4TX", "attributes": { "expires_in": 3600 } }] }

Ensuite, nous utilisons le token.id pour définir l'en-tête d' Authorization de notre bibliothèque et renvoyons l'utilisateur, ce qui revient à demander l'utilisateur via User.find() comme nous l'avons fait auparavant.

Maintenant, si vous appelez User.authenticate(email, password) , vous recevrez un utilisateur authentifié en réponse, et toutes les demandes futures seront autorisées avec un jeton d'accès.

ActiveResource.js permet le développement rapide de SDK JavaScript

Dans ce didacticiel, nous avons exploré les façons dont ActiveResource.js peut vous aider à créer rapidement un SDK JavaScript pour gérer vos ressources d'API et leurs diverses ressources associées, parfois compliquées. Vous pouvez voir toutes ces fonctionnalités et plus documentées dans le README pour ActiveResource.js.

J'espère que vous avez apprécié la facilité avec laquelle ces opérations peuvent être effectuées et que vous utiliserez (et peut-être même contribuerez à) ma bibliothèque pour vos futurs projets si cela correspond à vos besoins. Dans l'esprit de l'open source, les relations publiques sont toujours les bienvenues !