Command Palette

Search for a command to run...

Миграция провайдера модуля Medusa на Модуль интеграций

В этом руководстве вы узнаете, как мигрировать существующий провайдер Medusa на управление своими настройками через Модуль интеграций.

Что такое Модуль интеграций? 

Модуль интеграций позволяет любому плагину описывать свои параметры в виде схемы, а администраторам магазина настраивать их в Admin, без правок и без повторного развёртывания приложения Medusa. Он генерирует UI, предоставляет CRUD API и валидацию, поэтому вам не нужно писать свои модели данных, роуты, формы или воркфлоу.


Зачем мигрировать

Когда параметры провайдера заданы в , смена токена, пароля или любой другой настройки означает правку кода, обновление переменных окружения и повторное развёртывание Medusa. Модуль интеграций избавляет от этой рутины. Он строит UI для управления настройками провайдера по простому декларативному описанию их схемы, и администратор магазина управляет ими в Admin, без правок и без повторного развёртывания.

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

  • Избавиться от лишнего кода. Вам не нужно поддерживать модели данных, роуты, UI-формы, валидацию и воркфлоу.
  • Получить UI по стандартам Medusa, чтобы ваш провайдер выглядел и работал как нативная интеграция.
  • Хранить секреты зашифрованными, а не в открытом виде.
  • Проверять соединение с внешним сервисом прямо из Admin.
  • Подключать несколько инстансов провайдера, каждый со своими настройками.

Пример миграции

В этом руководстве примеры кода взяты из реальной миграции нашего провайдера модуля фулфилмента ApiShip.

До миграции настройки ApiShip хранились в : токен, подключения к курьерским службам, адрес отправителя по умолчанию, габариты товара и налоговые настройки. Эти данные обслуживал собственный Admin UI, и на каждую CRUD-операцию был отдельный API-роут. Модуль интеграций сводит всё это в один раздел Настройки → Интеграции.

Список параметров провайдера одноуровневый, и каждый параметр соответствует одному элементу в генерируемой UI-форме. Настройки становятся параметрами дескриптора, а в собственный UI переносится только то, для чего в каталоге нет подходящего элемента, например, для параметров с типом . У провайдера ApiShip в собственный UI попадает только список подключений к курьерским службам, потому что он представляет собой массив объектов JSON.

Шаг 1: Опишите дескриптор

Рядом с существующим провайдером доставки создайте новый провайдер Модуля интеграций в файле и опишите в нём дескриптор. Это тот же список настроек, что и раньше, но каждое поле теперь объявлено явно: тип, обязательность, значение по умолчанию и способ отображения в UI-форме.

Structure
1src/providers/
2├── integration-apiship/ # новый: дескриптор настроек для Модуля интеграций
3└── fulfillment-apiship/ # существующий: провайдер модуля фулфилмента, который читает настройки через Модуль интеграций

Директория содержит дескриптор настроек. остаётся существующим провайдером модуля фулфилмента.

providers/integration-apiship/services/apiship-integration.ts
1import { AbstractIntegrationProvider, defineIntegration, z } from "@gorgo/medusa-integration"
2import { ProviderKeys } from "../../../types"
3import { APISHIP_ICON } from "../icon"
4
5const descriptor = defineIntegration({
6 category: "fulfillment",
7 displayName: "apiship.name",
8 description: "apiship.description",
9 icon: APISHIP_ICON,
10 preferredLayoutId: "core:two-column",
11 supportsMultipleInstances: true,
12
13 options: {
14 token: {
15 type: "string",
16 required: true,
17 secret: true,
18 control: "secret",
19 label: "apiship.fields.token"
20 },
21 is_test: {
22 type: "boolean",
23 default: false,
24 control: "switch",
25 label: "apiship.fields.isTest"
26 },
27 is_cod: {
28 type: "boolean",
29 default: false,
30 control: "switch",
31 label: "apiship.fields.isCod"
32 },
33 delivery_cost_vat: {
34 type: "enum",
35 // Значения однородные: здесь числа, поэтому ставка остаётся числом на всём пути.
36 values: [-1, 0, 5, 10, 20 /* ... */],
37 default: -1,
38 control: "select",
39 label: "apiship.fields.deliveryCostVat",
40 // Уходит в ApiShip только вместе с наложенным платежом, иначе поле скрыто.
41 visibleWhen: {
42 field: "is_cod",
43 equals: true
44 },
45 valueLabels: {
46 "-1": "apiship.vat.noVat",
47 "0": "apiship.vat.vat0",
48 "5": "apiship.vat.vat5",
49 "10": "apiship.vat.vat10",
50 "20": "apiship.vat.vat20"
51 /* ... */
52 },
53 },
54 default_product_length: {
55 type: "number",
56 default: 10,
57 positive: true,
58 control: "number",
59 label: "apiship.fields.defaultProductLength"
60 },
61 // ...width, height, weight
62 sender_country_code: {
63 type: "enum",
64 values: ["RU", "KZ" /* ... */],
65 control: "select",
66 label: "apiship.fields.senderCountryCode"
67 },
68 // ...sender_address_string, sender_contact_name, sender_phone
69 // Подключения к курьерским службам образуют список записей, а контрола для этого
70 // в каталоге нет, поэтому у них остаётся свой Admin UI (см. шаг 4). Но они всё равно
71 // часть схемы: шифруются и валидируются наравне с остальными полями.
72 settings: {
73 type: "json",
74 default: {
75 connections: []
76 },
77 control: "json",
78 label: "apiship.fields.settings"
79 },
80 },
81
82 sections: [
83 {
84 id: "credentials",
85 title: "apiship.sections.credentials",
86 options: ["token", "is_test"]
87 },
88 {
89 id: "payment_and_tax",
90 title: "apiship.sections.paymentAndTax",
91 column: "side",
92 options: ["is_cod", "delivery_cost_vat"]
93 },
94 {
95 id: "default_product_sizes",
96 title: "apiship.sections.defaultProductSizes",
97 column: "side",
98 options: ["default_product_length" /* ...width, height, weight */]
99 },
100 {
101 id: "sender",
102 title: "apiship.sections.sender",
103 options: ["sender_country_code" /* ...адрес, ФИО, телефон */]
104 },
105 ],
106
107 testConnection: async ({ options }) => {
108 // Лёгкий read-only вызов к API ApiShip, подтверждающий валидность токена.
109 // Возвращает { status, message }, исключений не бросает.
110 },
111})
112
113export type ApishipIntegrationOptions = z.infer<typeof descriptor.optionsSchema>
114
115export class ApishipIntegrationProvider extends AbstractIntegrationProvider {
116 static identifier = ProviderKeys.APISHIP
117
118 get descriptor() {
119 return descriptor
120 }
121}
122
123export default ApishipIntegrationProvider

Это распределяет настройки по тем же четырём секциям, что были на старой странице: доступы, оплата и налоги, размеры товара и отправитель. Единственное исключение составляют подключения к курьерским службам: они остаются одним параметром типа вместо собственной страницы.

у параметра зашифрует это поле перед сохранением. Держите значения по умолчанию в одной общей константе и ссылайтесь на неё из . Резолвер применит их во время выполнения, форма редактирования подставит в поля, а карточка покажет как текущее значение, так что все три останутся согласованными.

Внимание: 

Параметры без не попадают в итоговую конфигурацию. Это то, что нужно, когда «пусто» является реальным состоянием поля. Адрес отправителя ApiShip, например, не нужен для расчёта цены, только для создания заказа, поэтому его проверяет провайдер в момент создания заказа, а не в дескрипторе. Если помечать такое поле , любая конфигурация, где оно не заполнено, станет неполной, а неполную интеграцию резолвер считает ненастроенной, то есть провайдер модуля фулфилмента перестанет работать и выдаст ошибку во время выполнения.

Шаг 2: Подключите провайдер в

medusa-config.ts
1// ...
2
3const APISHIP_INTEGRATION_ID = "apiship-1"
4
5module.exports = defineConfig({
6 // ...
7 plugins: [
8 {
9 resolve: "@gorgo/medusa-integration",
10 options: {
11 encryptionKey: process.env.INTEGRATION_ENCRYPTION_KEY,
12 providers: [
13 {
14 resolve: "@gorgo/medusa-fulfillment-apiship/providers/integration-apiship",
15 id: APISHIP_INTEGRATION_ID,
16 options: {},
17 },
18 ],
19 },
20 },
21 // ...
22 ],
23 modules: [
24 {
25 resolve: "@medusajs/medusa/fulfillment",
26 options: {
27 providers: [
28 {
29 resolve: "@gorgo/medusa-fulfillment-apiship/providers/fulfillment-apiship",
30 id: "apiship",
31 options: {
32 id: APISHIP_INTEGRATION_ID
33 },
34 },
35 ],
36 },
37 },
38 // ...
39 ],
40})

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

Внимание: 

У провайдера Модуля интеграций вы задаёте параметр , из него модуль собирает итоговый идентификатор инстанса вида . Такой же нужно передать провайдеру модуля фулфилмента, чтобы он знал, какие настройки читать. Если провайдер интеграций поддерживает несколько инстансов, у каждого будет свой , который можно передать разным провайдерам модуля фулфилмента.

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

.env
INTEGRATION_ENCRYPTION_KEY=supersecret

Значение здесь должно совпадать с тем, что читает в выше.

Шаг 3: Переключите на чтение опций через

Раньше провайдер модуля фулфилмента читал параметры из . Теперь он получает их из модуля интеграций через , передавая свой и инстанса (тот самый из ). Модуль возвращает значения параметров уже расшифрованными, проверенными и с применёнными значениями по умолчанию:

providers/fulfillment-apiship/core/apiship-base.ts
1// ...
2import { resolveIntegrationOptions } from "@gorgo/medusa-integration"
3import { ProviderKeys } from "../../../types"
4import { ApishipOptions } from "../../integration-apiship/services/apiship-integration"
5
6
7class ApishipBase extends AbstractFulfillmentProviderService {
8 protected instanceId_: string | null
9
10 constructor({ logger }, options?: Record<string, unknown>) {
11 super()
12 this.logger_ = logger
13 this.instanceId_ = (options?.id as string | undefined) ?? null
14 }
15
16 private async getApishipOptions_(): Promise<ApishipOptions> {
17 const options = await resolveIntegrationOptions<ApishipOptions>({
18 identifier: ProviderKeys.APISHIP,
19 instance_id: this.instanceId_,
20 })
21 return options
22 }
23
24 // ...
25}

, заданный из собственного регистрации в модуле фулфилмента, становится , который передаётся в . Поэтому каждый инстанс модуля фулфилмента получает настройки ровно того инстанса Модуля интеграций, с которым его связали в .

Шаг 4: Перенесите собственный UI

Раньше у ApiShip была своя страница настроек в Admin. Теперь эту страницу интеграции формирует Модуль интеграции по дескриптору: доступы, оплата и НДС, габариты товара и отправитель генерируются как секции.

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

admin/widgets/apiship-integration-main.tsx
1// ...
2import { defineWidgetConfig } from "@medusajs/admin-sdk"
3import type { IntegrationSectionData } from "@gorgo/medusa-integration"
4import { useApishipOptions } from "../hooks/api/apiship"
5
6const ApishipIntegrationWidget = ({ data }: { data: IntegrationSectionData }) => {
7 // Ключ инстанса от модуля: "int_apiship" или "int_apiship_<id>".
8 const providerId = data.providerId
9
10 // Читаем через собственный admin-роут, передавая ключ: виджет работает в браузере,
11 // поэтому получать параметры с сервера он сам не может.
12 const { apiship_options } = useApishipOptions(providerId)
13
14 return (
15 <>
16 // ...существующие компоненты, providerId передаётся дальше во все хуки и запросы
17 </>
18 )
19}
20
21export const config = defineWidgetConfig({
22 // зона расширения модуля: виджет встанет под сгенерированными секциями
23 zone: "gorgo.integration.apiship.main.after",
24})
25
26export default ApishipIntegrationWidget

Хук обращается к собственному admin-роуту, а не берёт список подключений из . Дело не в доступе к серверу. уже содержит несекретные параметры интеграции, включая . Дело в том, что подключения нужно редактировать по одной записи (добавлять, менять, удалять), а для этого нужен свой роут с обычным CRUD, а не разовый снимок.

Внимание: 

работает только на сервере. Он запускает воркфлоу в контейнере приложения. Виджет вместо этого читает через ваш собственный admin-роут, и этот роут решает, что отдавать: не возвращайте секреты в ответ, а всё, что уже покрыто секцией дескриптора, пусть отдаёт Модуля интеграций.

Записывать значения параметров нужно так же. Соберите новое значение своего -параметра и передайте в воркфлоу Модуля интеграций. Он ограничит запись объявленными параметрами, объединит их с сохранёнными значениями, проверит их, зашифрует секреты и отправит событие , чтобы сбросить кэш:

workflows/update-apiship-options.ts
1import { upsertIntegrationWorkflow } from "@gorgo/medusa-integration"
2
3// ...сначала собираем новый список подключений из сохранённого, затем:
4upsertIntegrationWorkflow.runAsStep({
5 input: transform({ input, settings }, (d) => ({
6 provider_id: d.input.provider_id ?? DEFAULT_APISHIP_PROVIDER_ID,
7 // Без `section_id`: виджет передаёт идентификаторы параметров напрямую.
8 values: { settings: d.settings },
9 })),
10})

Модуль передаёт в виджет готовый ключ инстанса через , так что передавать его в свои хуки и запросы можно напрямую. В итоге одна страница настроек для ApiShip совмещает сгенерированные секции и специфичный UI.


Итог

Миграция завершена. Теперь провайдер модуля фулфилмента получает настройки из Medusa Admin без правок кода и без повторного развёртывания, хранит секреты зашифрованными, по умолчанию поддерживает несколько инстансов и может проверять соединение с внешним API. Почти весь прежний собственный UI исчез, потому что модуль генерирует его из дескриптора, а то, что осталось, находится на одной странице со сгенерированными секциями.

Полный код провайдера модуля фулфилмента ApiShip после миграции доступен в репозитории .

Материалы

Изменено 12 августа 2026 г.·Редактировать страницу