В этом уроке мы разберем, как создавать кастомные API-запросы, которые позволят сторонним сервисам взаимодействовать с нашим сервером.
Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье Разворачивание проекта.
При разворачивании проекта используйте backup базы данных, который можете найти в архиве из раздела Ответы прошлого урока. Скопируйте папки Forms, Workflow и Patterns в папку с развернутым проектом, например, в папку D:\WT\Projects\Template\Projects\1. Template.
Инструкция по подключению шаблонов находится по ссылке.
Для тестирования API-запросов в уроке используется приложение Postman, которое можно скачать с официального сайта.
Создание пользователя
Все кастомные API-запросы, которые отправляются на сервер из мобильных приложений или с сайта, должны быть подписаны токеном, который создается при авторизации пользователя в программе.
Для начала добавим специального пользователя "Пользователь для сайта", под которым будет авторизовываться сторонний ресурс для доступа к API:
DO$BODY$DECLARE _permission_block_id smallint; _permission_block_item_id smallint; _permission_id smallint; _group_id smallint; _public_user_id smallint; _user_id smallint;BEGIN-- Добавление разрешенийINSERT INTO template.permission_block(id_title, title)VALUES ('site', 'Сайт')ON CONFLICT (id_title) DO NOTHING RETURNING permission_block_id INTO _permission_block_id;INSERT INTO template.permission_block_item(permission_block_id, id_title, title)VALUES (_permission_block_id, 'site_api', 'API-запросы для сайта')ON CONFLICT (id_title) DO NOTHING RETURNING permission_block_item_id INTO _permission_block_item_id;INSERT INTO template.permission(name, permission_block_item_id)VALUES ('SiteApiMethodPermission', _permission_block_item_id)ON CONFLICT (name) DO NOTHING RETURNING permission_id INTO _permission_id;INSERT INTO public.strings (strings_id, language_id, text_value)VALUES ('site', 1, 'Сайт'), ('site', 2, 'Site'), ('site_api', 1, 'API-запросы для сайта'), ('site_api', 2, 'Website API requests');-- Добавление разрешений-- Создание группыINSERT INTO template.group (name, title, description)VALUES ('UserForSiteGroup', 'Пользователи для сайта', 'Группа пользователей для работы с сайтом') RETURNING group_id INTO _group_id;INSERT INTO template.group_permission(group_id, permission_id)VALUES (_group_id, _permission_id)ON CONFLICT (group_id, permission_id) DO NOTHING;-- Создание группы-- Создание пользователяCREATE EXTENSION pgcrypto;INSERT INTO public.user(user_name, user_full_name, user_password, person)VALUES('USER_FOR_SITE', 'Пользователь для сайта', encode(digest('123', 'sha512'), 'hex'), false) RETURNING user_id INTO _public_user_id;INSERT INTO template.user(public_user_id)VALUES(_public_user_id) RETURNING user_id INTO _user_id;INSERT INTO template.user_group(user_id, group_id)VALUES(_user_id, _group_id);DROP EXTENSION pgcrypto;-- Создание пользователяEND;$BODY$;
Для пользователя создали группу, которой сразу назначили права доступа до SiteApiMethodPermission. Позже создадим это разрешение в серверном xml-файле.
Из запроса на получение списка групп пользователей необходимо исключить группу "Пользователи для сайта", чтобы нельзя было редактировать ее и выбирать при создании/редактировании пользователей.
Необходимо исключить блок "Сайт" из списка редактирования прав доступа для групп пользователей (PermissionBlockItemSelectSqlQuery).
Аутентификация
Запрос аутентификации
Для авторизации и получения токена используется POST-запрос /api/v1/tokens/signin.
LongToken - признак использования токена с большим временем жизни. Необязательное поле. Ожидается логическое значение. По умолчанию используется значение False.
Если LongToken имеет значение false, то время жизни токена равно 10 минутам. Если true, то 15 годам.
В ответ от сервера мы получим:
В ответе поля:
accessToken - основной токен доступа. Им необходимо подписывать все запросы;
refreshToken - токен, необходимый для обновления основного токена;
expires - срок жизни (точнее, срок годности) accessToken, указанный в секундах с 1970-01-01.
Запрос на обновление токена
Для обновления токена используется POST-запрос /api/v1/tokens/refresh:
В атрибуте Route тэга <ApiMethod> указываем конечную точку маршрута, по которому будет выполняться действие, описанное в ApiMethod. В нашем случае полный маршрут будет иметь вид: http://localhost:50707/data_api/unit_list.
Необязательный тэг <Response> описывает дополнительные поля в ответе на запрос. Таким образом, помимо системного поля result_code, будет добавлено поле units, содержащее массив объектов. За то, что поле будет возвращать массив, отвечает необязательный атрибут Array тэга <Object>. В качестве значения поля units будет подставляться результат выполнения команды UnitListSelectSqlQueryCommand типа SqlQueryCommand:
Template.xml
<CommandName="UnitListSelectSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> SELECT unit.unit_id, unit.title, short_title, unit.archive FROM template.unit ORDER BY unit.title; </Text> </SqlQuery></Command>
Добавим API-метод GetUnitListApiMethod в разрешение SiteApiMethodPermission, которое ранее добавили для группы "Пользователи для сайта":
Давайте перейдем в Postman и создадим запрос localhost:49707/data_api/unit_list для проверки метода:
Все запросы должны быть подписаны полученным при аутентификации jwt-токеном, поэтому в заголовок запроса добавили параметр Authorization со значением: Bearer <accessToken>.
В ответ на запрос сервер вернет json-объект:
GET-запрос с двумя объектами
Создадим команды для получения списков категорий ТМЦ и самих ТМЦ:
Template.xml
<CommandName="MaterialCategorySelectSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> SELECT MC.material_category_id, MC.parent_material_category_id, MC.title FROM template.material_category MC WHERE NOT MC.archive ORDER BY MC.title, MC.material_category_id; </Text> </SqlQuery></Command><CommandName="MaterialSelectSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> SELECT M.material_id, M.material_category_id, M.title, M.unit_id, M.unit_price FROM template.material M WHERE NOT M.archive ORDER BY M.title; </Text> </SqlQuery></Command>
В Postman создадим и выполним запрос localhost:49707/data_api/material_list:
В ответ на запрос сервер вернет json-объект:
В ответе мы видим два поля: material_category и material с массивами объектов.
GET-запрос с параметрами
Давайте создадим API-запрос, который по идентификатору клиента будет возвращать список его заказов.
Создадим команду:
Template.xml
<CommandName="OrdersByClientIdSelectSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> WITH _order AS ( SELECT O.order_id, O.client_id, O.order_number, O.order_date, O.description FROM template.order O WHERE O.client_id = {client_id} ), _order_position AS ( SELECT O.order_id, SUM(OP.quantity * OP.unit_price) AS total_cost FROM _order O JOIN template.order_position OP USING(order_id) LEFT JOIN template.material M USING(material_id) GROUP BY O.order_id ) SELECT O.order_id, O.order_number, O.order_date, COALESCE(OP.total_cost, 0) AS total_cost FROM _order O LEFT JOIN _order_position OP USING(order_id) ORDER BY O.order_number ASC; </Text> </SqlQuery></Command>
В тексте запроса используется переменная client_id. Вместо этой переменной будет подставляться значение параметра, полученного в HTTP-запросе.
В тэге <Parameters> можем перечислять все входные параметры. Для каждого параметра необходимо указать его тип и источник. В нашем случае для GET-запроса будем ожидать параметр client_id в строке запроса. Через атрибут Required укажем, что параметр является обязательным.
Выполним в Postman запрос localhost:49707/data_api/order_list?client_id=2:
В ответ на запрос сервер вернет json-объект:
Так как параметру client_id указали источник FromQuery (значение атрибута Source), то контроллер будет искать этот параметр в строке запроса.
Давайте расширим этот запрос, добавив в него список позиций заказа:
Template.xml
<CommandName="OrderPositionsByClientIdSelectSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> WITH _order AS ( SELECT O.order_id, O.client_id, O.order_number, O.order_date, O.description FROM template.order O WHERE O.client_id = {client_id} ) SELECT O.order_id, OP.order_position_id, OP.material_id, OP.quantity, OP.unit_price FROM _order O JOIN template.order_position OP USING(order_id) ORDER BY OP.order_position_id; </Text> </SqlQuery></Command>
Мы можем просто добавить список позиций заказа отдельным полем:
В ответе от сервера каждый объект заказа имеет массив позиций заказа.
Можно указывать несколько отношений. Добавьте команду на получение списка оплат в заказах для клиента и добавьте их в OrderListByClientIdApiMethod. Укажите в тэге <Relation> отношение списка оплат к списку заказов.
POST-запрос
В качестве примера POST-запроса создадим API-метод на добавление оплаты в заказ.
Для начала добавим в таблицу template.account колонку for_site для обозначения счета, на который будут привязываться оплаты с сайта, и добавим такой счет:
ALTERTABLE template.accountADD COLUMN for_site booleanNOT NULLDEFAULT false;INSERT INTO template.account (title, cash, for_site)VALUES ('Счет для оплат с сайта', false, true);
Создадим функцию для добавления оплаты через API-метод:
CREATE OR REPLACEFUNCTIONtemplate.order_payment_insert(IN in_order_id bigint,IN in_summ numeric)RETURNSTABLE(order_payment_id bigint, error_message character varying) AS$BODY$DECLARE _account_id bigint; _cash_id bigint; _order_payment_id bigint; _error_message varchar; _rec record;BEGIN-- Проверка входящих параметровSELECT*INTO _rec FROM template.order WHERE order_id = in_order_id; _account_id = account_id FROM template.account WHERE for_site LIMIT1;IF (_rec ISNULL) THEN _error_message ='no_order'; ELSIF (_rec.deleted) THEN _error_message ='order deleted'; ELSIF (_account_id ISNULL) THEN _error_message ='no_account';ENDIF;IF (_error_message !='') THENRETURN QUERY SELECT _order_payment_id, _error_message;RETURN;ENDIF;-- Проверка входящих параметровINSERT INTO template.cash( cash_date, account_id, operation_id, summ )SELECTnow(), _account_id, O.operation_id, in_summFROM template.operation OWHERE id_title ='OrderPaymentOperation' RETURNING cash_id INTO _cash_id;INSERT INTO template.order_payment( order_id, cash_id )VALUES( in_order_id, _cash_id ) RETURNING order_payment.order_payment_id INTO _order_payment_id;RETURN QUERY SELECT _order_payment_id, _error_message;END;$BODY$LANGUAGE plpgsql;
Функция будет возвращать идентификатор добавленной записи, либо ошибку, если передан неверный идентификатор заказа, заказ удален или отсутствует счет для оплат с сайта.
В тэге <Commands> можем описать последовательность команд, а в тэге <Response> обращаться к результату выполнения команд. При этом мы можем через атрибут Parameter обратиться к конкретному значению в результате команды.
Давайте создадим API-метод на сохранение заказа одним запросом. Т.е. помимо основной информации о заказе будем принимать и сохранять позиции заказа. У нас будет один метод на создание нового заказа и на редактирование существующего.
Первым делом в Postman создадим запрос localhost:49707/data_api/save_order:
Запрос будет передавать json-объект вида:
{"client_id":8,"order":{"order_id":null,"order_date":"2023-12-21 11:00:28Z","description":"Тестовый заказ с сайта","order_position":[ {"order_position_id":null,"material_id":4,"quantity":5,"unit_price":200 } ] }}
Для источника FromBody есть ограничение на обработку параметров относительно уровня вложенности объектов. В качестве параметров запроса можем указывать только поля основного объекта, которые принимают значение простых типов (строка, число, дата, логическое) и массив простых типов. Мы можем напрямую обратиться к полю client_id и получить его значение, но не можем обратиться к полям объекта order. Поэтому в ApiMethod мы укажем параметр order с типом Json:
Обратите внимание: даты между клиентом и сервером передаются в UTC, и в JSON-объекте они не приводятся автоматически к часовому поясу сервера. Для корректного сохранения даты со временем используем функцию public.convert_date_json().
Выполним тестовый запрос:
POST-запрос с файлами
Чтобы в кастомные API-запросы передавать файлы, HTTP-запрос должен использовать тип содержимого multipart/form-data.
Перейдем в Postman и создадим запрос localhost:49707/data_api/save_client:
Запрос содержит два объекта формы:
client - json-объект с описанием данных клиента;
file - передаваемый файл.
Объект client будет иметь вид:
{"client_id":null,"city_id":3,"date_of_birth":"1994-07-22","title":"Савушкин Степан Маркович","email":"test123@mail.ru","phone":"+79121234567"}
Если мы хотим передать на сервер массив файлов, то достаточно добавить в форму запроса еще один объект с тем же именем:
А в ApiMethod для параметра file добавить атрибут Array со значением True. В противном случае в запрос будет подставляться информация о последнем файле.
Команда SaveClientSqlQueryCommand будет иметь вид:
Template.xml
<CommandName="SaveClientSqlQueryCommand"Type="SqlQueryCommand"> <SqlQuery> <Text> SELECT template.save_client({client}::json, {file}::json) AS client_id; </Text> </SqlQuery></Command>
Получив соответствующий запрос, сервер загрузит файл, сгенерирует guid и добавит информацию о нем в таблицу public.file. Для переменной file будет сгенерирована json-строка в виде массива объектов с двумя полями: file_name (имя файла) и guid (сгенерированный идентификатор).
Таким образом, текст запроса с замененными переменными будет иметь вид:
SELECT template.save_client('{ "client_id":null, "city_id":3, "date_of_birth":"1994-07-22", "title":"Савушкин Степан Маркович", "email":"test123@mail.ru", "phone":"+79121234567"}'::json, '[ { "file_name": "ua1hNO_T_O8.jpg", "guid": "70b3464d-2731-4aef-9f52-89caa992109b" }]'::json) AS client_id;
Создайте самостоятельно функцию template.save_client(json, json), которая будет создавать нового клиента или обновлять данные существующего. В качестве результата функция должна возвращать идентификатор клиента.
В ответ на запрос сервер вернет json-объект вида:
Работа с файлами
Загрузка файла
Для загрузки файла на сервер используется системный POST-запрос /files/upload, который в форме запроса должен иметь объект с именем file. После сохранения файла на сервере в ответе в поле FileGuid вернется сгенерированный guid.
Скачивание файла
Для скачивания файлов с сервера используется системный GET-запрос /files/download, ожидающий обязательный параметр fileGuid - guid файла, который генерируется автоматически при загрузке файла на сервер.
Запрос должен быть подписан токеном, полученным при авторизации пользователя в программе.
Блокировка API-запросов
Скорректируйте форму настроек, добавив объект CheckBox для включения/отключения модуля для работы с сайтом и кнопку для смены пароля у пользователя для сайта.
По кнопке "Изменить пароль..." должна открываться форма смены пароля (TemplateUserPasswordEdit.xml), где для пользователя "Пользователь для сайта" (user_name = 'USER_FOR_SITE') можно задать новый пароль. Измените самостоятельно логику смены пароля.
В таблице template.settings добавим колонку для включения/отключения модуля сайта:
Если API-метод недоступен, то в ответ на запрос будет возвращаться код 404 Not Found:
Итоги
В этом уроке мы познакомились с возможностью создавать кастомные API-запросы для предоставления доступа сторонним сервисам, таким как сайты или мобильные приложения.
Помимо кастомных API-запросов рассмотрели штатные запросы для аутентификации пользователей и для работы с файлами.
Как маленький бонус: узнали о возможностях PostgreSQL по работе с JSON - в следующем уроке рассмотрим, как можно на форме создавать json-объекты для отправки их на сервер.
Ответы
В архиве присутствуют xml-файлы форм и серверный xml-файл, также лежит бэкап базы данных и файл с запросами на изменение структуры базы данных - с помощью файлов можете проверить себя.
Ниже прикреплен экспортированный из Postman файл с коллекцией запросов:
Чтобы импортировать коллекцию из файла, необходимо кликнуть по кнопке Import, и в отрывшемся окне выбрать файл коллекции.