Разработка приложения для Битрикс24 от А до Я. Часть 3 - реализуем настройки приложения
А вот и очередная часть нашего рассказа о разработке приложения для Битрикс24. Если вы не читали первые 2 части, рекомендуем ознакомиться для понимания картины. Вот первая часть, рассказывающая о постановке задачи и определении функций приложения, а вот вторая часть, где рассказано о том, как реализовать установку приложения.
Как и в прошлый раз приводим рассказ с сохранением стиля нашего руководителя отдела web-разработки - Вадима Солуянова.
Реализуем настройки приложения
В настройках, куда перенаправляет портал пользователя сразу после нашего подтверждения установки (помните js BX24.installFinish()), и куда пользователь может зайти также через левое меню портала, кликнув по имени приложения, мы выводим текущие значения ключей и даем возможность их установить/обновить, а также отправить тестовое СМС-сообщение. Таким образом наша точка входа (settings) должна отделять друг от друга три вида запросов:
- запрос с портала на открытие страницы настроек,
- запрос с самой страницы при submit-е формы сохранения ключей,
- запрос с самой страницы для отправки тестового СМС.
Бывает, что разработчики разделяют запросы по наличию пришедших данных, которые следует обработать. Однако, я настоятельно рекомендую для каждого конкретного действия либо слать предопределенный параметр с кодом действия, например, action=send_sms, либо отдельный параметр, наличие в запросе которого указывает на необходимость совершения действия, например, send_sms=Y. Почему? Прежде всего потому, что мы должны проверить валидность переданных данных, а что проверять, если мы не знаем какое действие от нас требуют? Сейчас поясню.
Скажем, в нашем приложении можно сохранение ключей делать только тогда, когда они пришли к нам непустыми. В этом случае, если пользователь ничего не заполнит и кликнет кнопку "Сохранить", мы ничего и делать не станем, даже не скажем ему, что он забыл указать ключи. Это, конечно, неверно. Потому и нужно отделять команду от ее данных. Мы получаем в параметре action команду сохранения ключей (action=save_settings), проверяем заполнены ли они, если нет - показываем ошибку, если да, то проверяем их валидность. Еще одно замечание по поводу использования имени кнопки submit-а для хранения команды. Ее значение, кажется, отправляется браузером не во всех случаях (или мои сведения устарели). Предпочтительнее все-таки добавлять в форму скрытое поле.
Теперь об авторизации. Токены, присланные порталом, нужны нам, если требуется делать какой-то запрос на портал. В данном случае этого не требуется, и можно данный вопрос опустить, но не хочется, поскольку в других приложениях они понадобятся. Когда приходит запрос с портала, с ним все понятно, токены - прямо в запросе. Когда мы сами стучимся к себе из фронта, например, отсабмитив форму, то тут уже все зависит от того, что мы добавили в скрытые поля. Именно в скрытые, и форма должна уходить методом POST, каким приходит и запрос с портала. Почему? Прежде всего для сокрытия конфиденциальных данных. Все, что передается через параметры УРЛ, т.е. методом GET попадает в логи серверов, через которые проходит наш запрос, а данные, переданные в POST-е, не логируются. Поэтому для сохранения авторизации присланные данные, а именно DOMAIN, LANG, AUTH_ID, REFRESH_ID, ну и member_id, конечно, следует сохранить в скрытых полях.
Но что делать, если в приложении нужно обратиться к самому себе через ссылку? Мы данный вариант использовать не будем, но скажу, что в этом случае придется сохранять авторизацию текущего пользователя в базу или сессию (лучше в базу в связи с последними нововведениями работы с cookies), генерить для данной записи уникальный непредсказуемый ключ (например md5 от сохраняемых параметров) и в ссылке указывать его. Состав полей при этом будет подобен нашему хранилищу 5 (Авторизация пользователя портала) с добавлением поля HASH. Другой вариант - это js. Сейчас в native js есть метод fetch(), который может отправлять данные на бэк методом post, даже никаких js-фреймворков не нужно. Отказ же от использования js не имеет смысла, поскольку в браузере с отключенными скриптами и приложение на портале не появится, поскольку открывается Битрикс24 путем submit-а формы через javascript. И третий вариант, через css можно сделать что угодно: ссылка будет отображаться как кнопка, кнопка - как ссылка, так что любую можно оформить как форму с method="post" и кнопкой type="submit".
При обработке запроса помним о безопасности. Нужно сказать, что member_id портала узнать можно, но проблематично, поэтому, если в запросе он присутствует и есть ему соответствующая запись в хранилище 1 (Сведения о портале), то будем считать запрос валидным. Если же приложение не столь простое, как наше, и требования безопасности к нему жесткие, то сохранение авторизации пользователя потребуется однозначно, как и проверка ее валидности. В этом случае придется делать запрос на сервер авторизации, что размещается по адресу https://oauth.bitrix.info/ а полный запрос будет выглядеть https://oauth.bitrix.info/rest/app.info. В случае, если на нем нет данных о приложении или портале, получите соответствующую ошибку. Да, для того, чтобы получить данные вам потребуется послать один параметр - auth=<здесь access_token пользователя> (опять-таки методом POST). Ну, можно изобрести и другие способы проверки, но для запроса с портала подойдет именно описанный выше.
Итак, все сказано, больные места обговорены, можно бы и составить уже схему работы настроек приложения.
Схема 4. Страница настроек приложения в псевдокоде:
errors = []
showForm = true
action = getRequest('action')
memberId = getRequest('member_id')
if not isMemberIdValid(memberId)
errors[] = 'Not valid request'
showForm = false
else
logCurrentRequest()
switch action
case 'save_settings':
saveSettings()
loadCurrentSettings()
break
case 'send_sms':
res = sendSms()
answerJson(res)
exit
break
default:
loadCurrentSettings()
break
if errors
errorMessage = createUserMessage()
logErrors()
logCurrentSettings()
includeTemplate()
if errorMessage
showErrorMessage()
if showForm
showSettingsForm()
if currentSettings
showTestForm()
Помним схему 1, где мы подробно расписывали действия при установке приложения, и понимаем, что многие из действий логировались в базу. Данному коду требуются пояснения. Во-первых, переменная showForm - в ней мы сохраняем флаг, стоит ли показывать во фронте форму или же запрос был совсем левый. Затем answerJson и exit - в данном случае к нам приходит запрос с фронта посредством вызова из javascript fetch() и нам не нужно продолжать выполнение, подключая шаблон. Требуется лишь вернуть результат отправки СМС в виде json объекта и выйти. Далее loadCurrentSettings - они по всей видимости должны передать в шаблон не только текущие настройки, но также URL для атрибута action формы настроек и формы отправки тестового SMS, а также скрытые поля, которые по меньшей мере содержат member_id, а в случае озабоченного безопасностью приложения еще и hash сохраненных в базе авторизационных данных, ну.., или сами данные в скрытых полях.
Что касается createUserMessage(). Здесь мы отказались от запроса на портал, иначе бы добавились те же варианты, что и при установке. В данном случае ошибки все возникают либо при невалидном запросе, либо при сохранении настроек в базу, либо в ответе SMS-провайдера. Все их необходимо проанализировать и выдать пользователю понятное сообщение. Пожалуй, стоит подробнее расписать вызов saveSettings().
Итак, сохранение настроек. В первую очередь мы проверяем присланные данные. Если чего-то не заполнено, сразу добавляем ошибку, но не обрываем выполнение. Очень неприятно как пользователю, когда ошибки всплывают одна за другой, а не все сразу, поэтому лучше проверить все данные и выдать пользователю информацию обо всех полях, где он ввел что-то неподходящее. Если все корректно, делаем запрос на получение информации об аккаунте (сейчас сами данные нас мало волнуют, важно, чтобы пришли). Если что-то не так с ключами, к нам возвратится ошибка. Ее мы показываем пользователю. Это, если API провайдера возвращает что-то человекочитаемое, а в противном случае формируем сообщение сами по коду ошибки. Если все Ок, и данные получены, сохраняем их в хранилище 1 (Сведения о портале). Вспоминаем, что нам достаточно одних авторизационных данных.
Схема 5. Итак, распишем в виде превдокода saveSettings():
if not apiLogin
errors[] = 'API Login is empty'
if not apiKey
errors[] = 'API Key is empty'
if errors
return
res = getAccountDetail(apiLogin, apiKey)
if not res
code = getHttpCode()
switch code
case 401:
errors[] = 'Wrong credetials'
break
//... other vars
default:
if !res
errors[] = 'SMS Service currently unavailable'
else
errors[] = getResponseError(res)
break
else
saveApiKeys()
Надо признаться, что в псевдокоде я сохраняю ошибки без разбора на то, какие они. Нужно ли их выводить непосредственно пользователю или же формировать на их основе более человечное сообщение. В реалии можно использовать разные классы ошибок и в массив добавлять их экземпляры. Таким образом (ох, я все-таки влез в архитектуру), мы задействуем полиморфизм ООП. Например, мы создаем такие классы:
- OurServerTemporaryError - выводит, что временная ошибка;
- OuterServerTemporaryError - выводит, что временная ошибка;
- OuterServerWrongCredetialsError - выводит, что указанные ключи невалидны;
- OuterServerError - выводит то, что вернул СМС-провайдер.
Затем, в методе createUserMessage нам достаточно будет вызывать метод getErrorMessage() и экземпляр соответствующего класса сам вернет то, что требуется пользователю.
И вижу, что в код закралась одна неприятная ошибка: в ветке default в наличии глупая проверка. Если посмотреть по коду выше, станет понятно, что вариант else здесь никогда не отработает. С другой стороны, сама строка в else выполняет вполне нужное действие. Вероятно, она просто находится не там. Поправим это:
Схема 6. saveSettings() в виде превдокода:
if not apiLogin
errors[] = 'API Login is empty'
if not apiKey
errors[] = 'API Key is empty'
if errors
return
res = getAccountDetail(apiLogin, apiKey)
if not res
code = getHttpCode()
switch code
case 401:
errors[] = 'Wrong credetials'
break
//... other vars
default:
errors[] = 'SMS Service currently unavailable'
break
else
if isResponseError(res)
errors[] = getResponseError(res)
else
saveApiKeys()
По отправке тестовых СМС, думаю, не стоит давать столь же подробные описания, поскольку они схожи с сохранением настроек. С одной оговоркой, ошибки там могут быть и другие. Например, недостаточно средств на счете или номер, на который отправлено сообщение, невалидный, или пользователю не позволяется отправлять международные СМС-сообщения. Эти все случаи, конечно, должны быть обработаны. Хорошо, когда в ошибке, возвращенной провайдером, имеется человекопонятное описание, но, если нет, придется его формировать самому.
Ну, с настройками и отправкой тестового СМС покончено. Думаю всем очевидно, что происходит во фронте на данной странице? Есть две формы. Одна с настройками, другая - с полями phone и message для тестового СМС. Первая отображается на странице всегда, если только сам запрос не был совсем левым. Вторая всплывает popup-ом при клике на кнопку Test (если сохраненная авторизация валидна, конечно). В обеих формах хранится в скрытом поле member_id, а при паранойе и hash или все авторизационные данные, присланные порталом. Первая форма отправляется обычным submit-ом, вторая - через js и функцию fetch(). Обе - методом POST. Да, и, конечно, на странице есть область, в которой выводятся ошибки. Даже две: одна где-то возле формы настроек, другая в popup-е возле формы отправки тестового SMS.
Итак, готово. Думаю, следующие точки входа принесут новые проблемы и изменения в уже сложившейся схеме. Но, как говорится, проект становится понятным лишь только после его завершения.
Обработка команд на отправку СМС (handler)
Наш handler может быть вызван из разных мест Битрикс24. Это может быть срабатывание автоматизации при изменении статуса сделки, или менеджер, находясь в карточке контакта, захочет отправить ему SMS, в любом случае, если в качестве провайдера он выберет наше приложение, то в итоге портал постучится к нам. Запрос будет содержать среди прочего такие данные:
- type => SMS
- code => код нашего приложения, точнее зарегистрированного провайдера
- message_id => буквы, цифры, идентифицирующие данное сообщение на портале
- message_to => +11112243111
- message_body => Hello my friend. I'm going to tell you that something goes wrong...
Ой, ой, ой... Кажется, при описании логики сохранения авторизации в настройках я совсем забыл о регистрации провайдера на портале. Так что пока менеджер не сможет выбрать наше приложение. Исправим это. Не будем повторять весь псевдокод saveSettings(), опишем лишь то место, где валидная авторизация сохраняется в базу. Вот как это выглядело:
if isResponseError(res)
errors[] = getResponseError(res)
else
saveApiKeys()
Добавим регистрацию SMS-провайдера в случае успешного сохранения ключей и отсутствия предыдущей регистрации.
if isResponseError(res)
errors[] = getResponseError(res)
else
oldKeys = getOldKeys()
res = saveApiKeys()
if res.success()
if empty oldKeys
res = registerProvider()
if not res.success()
errors[] = res.getError()
else
errors[] = res.getError()
Ну, вот теперь можно возвращаться к handler-у и, прежде всего, вспомнить про безопасность. Запрос на отправку SMS во многом подобен запросу на обработку события Битрикс24, а события в нем имеют один параметр, специально предназначенный для проверки валидности запроса. Параметр этот содержится в массиве под ключом auth, в котором лежит следующее:
- access_token = ...
- expires_in = 3600
- scope = crm,im,user,department,bizproc
- domain = ...
- server_endpoint = https://oauth.bitrix.info/rest/
- status = F
- client_endpoint = .../rest/
- member_id = ...
- user_id = 8
- application_token = ...
Последний параметр (application_token) - это и есть тот ключ, по которому мы сможем защитить приложение от левых запросов. И тут выявляется необходимость подправить наш список событий, на которые мы подписывались в момент установки, а также подправить наше хранилище 1 (Сведения о портале).
Для проверки нам потребуется сравнить присланное значение application_token с тем, что мы сохранили ранее у себя. А как и в какой момент мы получаем это значение для его сохранения? Сделать это можно, подписавшись на событие Битрикс24 OnAppInstall. Именно при этом событии к нам впервые приходит application_token. Конечно, он приходит и при любом другом событии, и нет уж такой необходимости подписываться на еще одно. Можно делать дополнительную проверку на валидность запроса в тот момент, когда у нас сохраненное значение отсутствует, запоминать при успешной проверке, а затем уже сравнивать присланный токен с тем, что у нас есть. Но, мне кажется, что проще подписаться, делать проверку именно при событии OnAppInstall, а во всех других событиях проверять по одной схеме, а именно - сравнивая присланное с сохраненным. Стало быть, дополним наш список действий:
18. Обработать OnAppInstall. Проверить валидность запроса через сервер авторизации, при успехе сохранить присланный application_token в хранилище 1 (Сведения о портале).
И добавим поле в само хранилище:
APP_TOKEN - токен для проверки валидности событий и запросов на отправку SMS
Теперь еще немного о самом запросе на отправку. Запрос, как вы видели приходит сразу с авторизацией, среди которой указан и ID пользователя. Ну, когда менеджер жмакает по кнопке SMS в карточке клиента, тогда понятно, что за пользователь к нам придет. Но если запрос приходит при срабатывании автоматизации, когда, например, сделка меняет свой статус? В этом случае к нам придет ID ответственного за сделку. Почему я начал говорить об этом? Дело в том, что ошибки при отправке очень даже возможны, но handler не содержит пользовательского интерфейса, в котором мы могли бы их вывести. Что же делать? Для этого в Битрикс24 существует возможность отправки пользователю нотификаций, и есть соответствующий REST-метод. Метод этот доступен тем приложениям, которые запросили доступ (scope) im. И выходит, что я допустил еще одну промашку, забыв упомянуть о доступах приложения. Чтобы не отвлекаться сейчас от handler-а, давайте обсудим этот вопрос чуть позже, когда закончим с отправкой SMS.
Схема 7. Отправка SMS в псевдокоде:
errors = []
auth = getRequest('auth)
if not isMemberIdValid(auth.member_id)
exit
if not isAppTokenValid(auth.application_token)
exit
logCurrentRequest()
messageId = getRequest('message_id')
messageTo = getRequest('message_to')
messageBody = getRequest('message_body')
if empty messageTo or empty messageBody
errors[] = 'Empty required data'
if not errors
res = saveUserAuth(auth)
if res.success()
errors[] = res.getErrorMessage()
if not errors
res = sendSMS(messageTo, messageBody)
if res.success()
saveRes = saveMessage(auth.user_id, messageId, res)
if not saveRes.success()
errors[] = saveRes.getErrorMessage()
if errors
logErrors()
message = getErrorMessage(errors)
sendNotification(auth.user_id, message)
Поскольку в данном месте отсутствует пользовательский интерфейс вообще, нет смысла выводить какое-либо сообщение (или, тем более, отсылать его) при невалидном запросе, поэтому мы лишь вызываем exit (прекратить дальнейшее выполнение).
saveUserAuth() - здесь мы впервые запоминаем авторизацию пользователя, поскольку она нам понадобится в дальнейшем при смене статуса сообщения. Если записи у нас нет, добавляем, если есть, обновляем токены.
saveMessage() - сохраняем у себя идентификатор сообщения на портале, оный же у SMS-провайдера, запоминаем текущий статус сообщения (например, queued - поставлено в очередь), пользователя и прочие служебные поля типа домена, идентификатора портала и т.п.
sendNotification() - отправляем нотификацию об ошибке тому пользователю, к которому привязано сообщение. Это либо менеджер, отправляющий СМС из карточки контакта, либо ответственный, напр., за сделку, чей статус поменялся, в результате чего сработала автоматизация.Пожалуй, на сегодня достаточно. Последнюю часть этой занимательной инструкции опубликуем на следующей неделе, а вы пока что подпишитесь на нас в социальных сетях, чтобы не пропускать интересные статьи и обзоры.