ActiveResource.js: Быстрое создание мощного SDK JavaScript для JSON API

Опубликовано: 2022-03-11

Ваша компания только что запустила свой API и теперь хочет создать вокруг него сообщество пользователей. Вы знаете, что большинство ваших клиентов будут работать на JavaScript, потому что сервисы, предоставляемые вашим API, облегчают клиентам создание веб-приложений вместо того, чтобы писать все самим — Twilio — хороший тому пример.

Вы также знаете, что каким бы простым ни был ваш RESTful API, пользователи захотят добавить пакет JavaScript, который сделает за них всю тяжелую работу. Они не захотят изучать ваш API и создавать каждый запрос, который им нужен, самостоятельно.

Итак, вы создаете библиотеку вокруг своего API. Или, может быть, вы просто пишете систему управления состоянием для веб-приложения, которое взаимодействует с вашим собственным внутренним API.

В любом случае, вы не хотите повторяться снова и снова каждый раз, когда вы выполняете CRUD для одного из ваших ресурсов API или, что еще хуже, для ресурса, связанного с этими ресурсами. Это не очень хорошо для управления растущим пакетом SDK в долгосрочной перспективе и не является хорошим использованием вашего времени.

Вместо этого вы можете использовать ActiveResource.js, систему JavaScript ORM для взаимодействия с API. Я создал его, чтобы удовлетворить нашу потребность в проекте: создать JavaScript SDK в как можно меньшем количестве строк. Это обеспечило максимальную эффективность для нас и для нашего сообщества разработчиков.

Он основан на принципах простой ActiveRecord ORM Ruby on Rails.

Принципы SDK для JavaScript

Есть две идеи Ruby on Rails, которыми руководствовались при разработке ActiveResource.js:

  1. «Соглашение важнее конфигурации:» Сделайте некоторые предположения о характере конечных точек API. Например, если у вас есть ресурс Product , он соответствует конечной точке /products . Таким образом, время не тратится на повторную настройку каждого из ваших запросов SDK к вашему API. Разработчики могут добавлять новые ресурсы API со сложными CRUD-запросами в ваш растущий SDK за считанные минуты, а не часы.
  2. «Возвышайте красивый код»: создатель Rails DHH сказал это лучше всего — в красивом коде есть что-то прекрасное ради самого себя. ActiveResource.js иногда оборачивает некрасивые запросы в красивый внешний вид. Вам больше не нужно писать собственный код для добавления фильтров и разбиения на страницы, а также для включения отношений, вложенных в отношения, в запросы GET. Вам также не нужно создавать запросы POST и PATCH, которые вносят изменения в свойства объекта и отправляют их на сервер для обновления. Вместо этого просто вызовите метод в ActiveResource: больше не нужно играть с JSON, чтобы получить нужный запрос, только для того, чтобы сделать это снова для следующего.

Прежде чем мы начнем

Важно отметить, что на момент написания этой статьи ActiveResource.js работал только с API, написанными в соответствии со стандартом JSON:API.

Если вы не знакомы с JSON:API и хотите продолжить, есть много хороших библиотек для создания сервера JSON:API.

Тем не менее, ActiveResource.js — это скорее DSL, чем оболочка для одного конкретного стандарта API. Интерфейс, который он использует для взаимодействия с вашим API, может быть расширен, поэтому в будущих статьях может быть рассказано, как использовать ActiveResource.js с вашим пользовательским API.

Настройка вещей

Для начала установите в свой проект active-resource :

 yarn add active-resource

Первый шаг — создать ResourceLibrary для вашего API. Я собираюсь поместить все свои ActiveResource в папку src/resources :

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

Единственный обязательный параметр для createResourceLibrary — это корневой URL-адрес вашего API.

Что мы создадим

Мы собираемся создать библиотеку JavaScript SDK для API системы управления контентом. Это означает, что будут пользователи, сообщения, комментарии и уведомления.

Пользователи смогут читать, создавать и редактировать сообщения; читать, добавлять и удалять комментарии (к сообщениям или другим комментариям), а также получать уведомления о новых сообщениях и комментариях.

Я не собираюсь использовать какую-либо конкретную библиотеку для управления представлением (React, Angular и т. д.) или состоянием (Redux и т. д.), вместо этого абстрагируя учебник для взаимодействия только с вашим API через ActiveResource .

Первый ресурс: Пользователи

Начнем с создания ресурса User для управления пользователями CMS.

Во-первых, мы создаем класс ресурсов User с некоторыми 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);

Давайте пока предположим, что у вас есть конечная точка проверки подлинности, которая, как только пользователь отправит свой адрес электронной почты и пароль, возвращает токен доступа и идентификатор пользователя. Эта конечная точка управляется некоторой функцией requestToken . Как только вы получите аутентифицированный идентификатор пользователя, вы хотите загрузить все данные пользователя:

 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); }

Я установил для library.headers заголовок Authorization с accessToken , чтобы все будущие запросы моей ResourceLibrary были авторизованы.

В следующем разделе будет рассказано, как аутентифицировать пользователя и установить токен доступа, используя только класс ресурсов User .

Последним шагом authenticate является запрос к User.find(id) . Это сделает запрос к /api/v1/users/:id , и ответ может выглядеть примерно так:

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

Ответ от authenticate будет экземпляром класса User . Отсюда вы можете получить доступ к различным атрибутам аутентифицированного пользователя, если хотите отобразить их где-нибудь в приложении.

 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 } */

Каждое из имен атрибутов будет иметь верблюжий регистр, чтобы соответствовать типичным стандартам JavaScript. Вы можете получить каждый из них непосредственно как свойства объекта user или получить все атрибуты, вызвав user.attributes() .

Добавление индекса ресурса

Прежде чем мы добавим дополнительные ресурсы, относящиеся к классу User , такие как уведомления, мы должны добавить файл src/resources/index.js , который будет индексировать все наши ресурсы. Это имеет два преимущества:

  1. Это очистит наш импорт, позволив нам деструктурировать src/resources для нескольких ресурсов в одном операторе импорта вместо использования нескольких операторов импорта.
  2. Он инициализирует все ресурсы в ResourceLibrary , которые мы создадим, вызвав для каждого из них library.createResource , что необходимо ActiveResource.js для построения отношений.
 // /src/resources/index.js import User from './User'; export { User };

Добавление связанного ресурса

Теперь давайте создадим связанный ресурс для User Notification . Сначала создайте класс Notification , belongsTo классу User :

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

Затем мы добавляем его в индекс ресурсов:

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

Затем свяжите уведомления с классом User :

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

Теперь, когда мы вернем пользователя из authenticate , мы можем загрузить и отобразить все его уведомления:

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

Мы также можем включить уведомления в исходный запрос для аутентифицированного пользователя:

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

Это одна из многих опций, доступных в DSL.

Обзор DSL

Давайте рассмотрим то, что уже можно запросить только из кода, который мы написали до сих пор.

Вы можете запросить группу пользователей или одного пользователя.

 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' });

Вы можете изменить запрос, используя цепные реляционные методы:

 // 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();

Обратите внимание, что вы можете составить запрос, используя любое количество связанных модификаторов, и что вы можете завершить запрос с помощью .all() , .first() , .last() или .each() .

Вы можете создать пользователя локально или создать его на сервере:

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

Если у вас есть постоянный пользователь, вы можете отправить ему изменения для сохранения на сервере:

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

Вы также можете удалить его с сервера:

 await user.destroy();

Этот базовый DSL распространяется и на связанные ресурсы, как я продемонстрирую в оставшейся части руководства. Теперь мы можем быстро применить ActiveResource.js для создания остальной части CMS: постов и комментариев.

Создание сообщений

Создайте класс ресурсов для Post и свяжите его с классом 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'); } }

Также добавьте Post в индекс ресурса:

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

Затем привяжите ресурс Post к форме, чтобы пользователи могли создавать и редактировать сообщения. Когда пользователь впервые посещает форму для создания нового поста, будет создан ресурс Post , и каждый раз, когда форма изменяется, мы применяем изменение к Post :

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

Затем добавьте в форму обратный вызов onSubmit , чтобы сохранить сообщение на сервере и обработать ошибки, если попытка сохранения не удалась:

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

Редактирование сообщений

Как только сообщение будет сохранено, оно будет связано с вашим API как ресурс на вашем сервере. Вы можете узнать, сохраняется ли ресурс на сервере, вызвав persisted :

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

Для постоянных ресурсов ActiveResource.js поддерживает грязные атрибуты, поскольку вы можете проверить, не изменился ли какой-либо атрибут ресурса по сравнению с его значением на сервере.

Если вы вызовете save() для сохраняемого ресурса, он сделает запрос PATCH , содержащий только изменения, внесенные в ресурс, вместо того, чтобы без необходимости отправлять на сервер весь набор атрибутов и отношений ресурса.

Вы можете добавить отслеживаемые атрибуты к ресурсу, используя объявление attributes . Давайте отследим изменения в post.content :

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

Теперь, когда сообщение сохраняется на сервере, мы можем редактировать сообщение, а при нажатии кнопки отправки сохранять изменения на сервере. Мы также можем отключить кнопку отправки, если еще не было внесено никаких изменений:

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

Существуют методы для управления отдельными отношениями, такими как post.user() , если мы хотим изменить пользователя, связанного с публикацией:

 await post.updateUser(user);

Это эквивалентно:

 await post.update({ user });

Ресурс комментариев

Теперь создайте класс ресурса Comment и свяжите его с Post . Помните наше требование, чтобы комментарии могли быть ответом на сообщение или другой комментарий, поэтому соответствующий ресурс для комментария полиморфен:

 // /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);

Не забудьте также добавить Comment в /src/resources/index.js .

Нам также нужно добавить строку в класс Post :

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

Параметр inverseOf , переданный в определение hasMany для replies , указывает, что это отношение является обратным полиморфному определению belongsTo для resource . Свойство отношений inverseOf часто используется при выполнении операций между отношениями. Как правило, это свойство определяется автоматически через имя класса, но поскольку полиморфные отношения могут быть одним из нескольких классов, вы должны самостоятельно определить параметр inverseOf , чтобы полиморфные отношения имели те же функции, что и обычные.

Управление комментариями к сообщениям

Тот же DSL, который применяется к ресурсам, также применяется к управлению связанными ресурсами. Теперь, когда мы настроили отношения между сообщениями и комментариями, мы можем управлять этими отношениями несколькими способами.

Вы можете добавить новый комментарий к посту:

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

Вы можете добавить ответ на комментарий:

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

Вы можете отредактировать комментарий:

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

Вы можете удалить комментарий к посту:

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

Отображение сообщений и комментариев

SDK можно использовать для отображения списка сообщений с разбивкой на страницы, и при нажатии на сообщение оно загружается на новую страницу со всеми его комментариями:

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

Приведенный выше запрос извлечет 10 самых последних сообщений, и для оптимизации единственным загружаемым атрибутом будет их content .

Если пользователь нажимает кнопку, чтобы перейти на следующую страницу сообщений, обработчик изменений извлечет следующую страницу. Здесь мы также отключаем кнопку, если нет следующих страниц.

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

Когда нажимается ссылка на сообщение, мы открываем новую страницу, загружая и отображая сообщение со всеми его данными, включая его комментарии, известные как ответы, а также ответы на эти ответы:

 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() ); }); }

Вызов .target() для отношения hasMany , такого как post.replies() , вернет ActiveResource.Collection комментариев, которые были загружены и сохранены локально.

Это различие важно, потому что post.replies().target().first() вернет первый загруженный комментарий. Напротив, post.replies().first() вернет обещание для одного комментария, запрошенного из GET /api/v1/posts/:id/replies .

Вы также можете запросить ответы на публикацию отдельно от запроса на саму публикацию, что позволит вам изменить свой запрос. Вы можете связать модификаторы, такие как order , select , perPage , where , includes , page при запросе отношений hasMany точно так же, как при запросе самих ресурсов.

 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()); }

Изменение ресурсов после их запроса

Иногда вы хотите взять данные с сервера и изменить их перед использованием. Например, вы можете обернуть post.createdAt в объект moment() , чтобы вы могли отображать удобную дату и время для пользователя о том, когда сообщение было создано:

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

неизменность

Если вы работаете с системой управления состоянием, которая предпочитает неизменяемые объекты, все поведение в ActiveResource.js можно сделать неизменяемым, настроив библиотеку ресурсов:

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

Возвращаясь назад: привязка системы аутентификации

В завершение я покажу вам, как интегрировать вашу систему аутентификации пользователей в ваш User ActiveResource .

Переместите свою систему аутентификации токенов в конечную точку API /api/v1/tokens . Когда адрес электронной почты и пароль пользователя отправляются на эту конечную точку, в ответ будут отправлены данные аутентифицированного пользователя и токен авторизации.

Создайте класс ресурса Token , который принадлежит User :

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

Добавьте /src/resources/index.js Token

Затем добавьте статический метод authenticate в свой класс ресурсов User и свяжите User to 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; } }

Этот метод использует resourceLibrary.interface() , который в данном случае является интерфейсом JSON:API, для отправки пользователя в /api/v1/tokens . Это действительно так: конечная точка в JSON:API не требует, чтобы единственными типами, отправляемыми в нее и из нее, были те, в честь которых она названа. Итак, запрос будет:

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

Ответом будет аутентифицированный пользователь с включенным токеном аутентификации:

 { "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 } }] }

Затем мы используем token.id для установки заголовка Authorization нашей библиотеки и возвращаем пользователя, что аналогично запросу пользователя через User.find() , как мы делали раньше.

Теперь, если вы User.authenticate(email, password) , вы получите в ответ аутентифицированного пользователя, и все будущие запросы будут авторизованы токеном доступа.

ActiveResource.js обеспечивает быструю разработку JavaScript SDK

В этом руководстве мы рассмотрели, как ActiveResource.js может помочь вам быстро создать пакет SDK для JavaScript для управления вашими ресурсами API и их различными, иногда сложными, связанными ресурсами. Вы можете увидеть все эти функции и многое другое в документации README для ActiveResource.js.

Я надеюсь, вам понравилась простота выполнения этих операций, и что вы будете использовать (и, возможно, даже вносить свой вклад) мою библиотеку для своих будущих проектов, если она соответствует вашим потребностям. В духе открытого исходного кода PR всегда приветствуются!