artefactby-ozon-seller-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 artefactby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,196 @@
1
+ # artefactby-ozon-seller-api
2
+
3
+ Unofficial typed [Ozon Seller API](https://docs.ozon.ru/api/seller/) client for
4
+ TypeScript and JavaScript.
5
+
6
+ [README на русском](./README.md)
7
+
8
+ ```ts
9
+ import { OzonClient } from 'artefactby-ozon-seller-api';
10
+
11
+ const client = new OzonClient({
12
+ clientId: process.env.OZON_CLIENT_ID!,
13
+ apiKey: process.env.OZON_API_KEY!,
14
+ });
15
+
16
+ // `items` is typed from the path literal — no generics, no casts
17
+ const { items } = await client.request('/v5/product/info/prices', {
18
+ cursor: '',
19
+ limit: 100,
20
+ filter: { visibility: 'ALL' },
21
+ });
22
+ ```
23
+
24
+ - **The whole API, not a curated subset.** Types are generated from the official
25
+ OpenAPI spec (API version 2.1): 458 operations, 2083 schemas. Every path is called
26
+ the same way, with the request body and response inferred from the path literal.
27
+ - **Zero runtime dependencies.** Native `fetch` only. Node.js >= 18, CJS and ESM.
28
+ - **A built-in rate limiter that knows Ozon's limits** — 50 rps per Client-Id plus the
29
+ per-method rates from the spec — as a separate subpath: skip it and it costs nothing.
30
+ - **Careful retries.** Nearly every Ozon operation is a POST, so only calls rejected
31
+ with 429 or "Circle is open" are retried: those the API demonstrably did not process.
32
+ Retrying "just in case" is deliberately not a thing this client does.
33
+
34
+ Status: 0.x — the public API may still change between minor releases. Not affiliated
35
+ with Ozon.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install artefactby-ozon-seller-api
41
+ ```
42
+
43
+ One client instance is bound to one Client-Id. The package never reads credentials from
44
+ the environment on its own — pass them to the constructor explicitly.
45
+
46
+ ## Rate limiting
47
+
48
+ Ozon answers 429 with `Retry-After` when a limit is exceeded and blocks a method for a
49
+ few minutes ("Circle is open") after a burst. The client honours `Retry-After` on its
50
+ own and repeats a rejected call up to `maxRetries` times (2 by default). To avoid
51
+ hitting the limits at all, install the built-in limiter — it paces calls in advance:
52
+
53
+ ```ts
54
+ import { OzonClient } from 'artefactby-ozon-seller-api';
55
+ import { TokenBucketLimiter } from 'artefactby-ozon-seller-api/limiter';
56
+
57
+ const client = new OzonClient({
58
+ clientId,
59
+ apiKey,
60
+ limiter: new TokenBucketLimiter({
61
+ // The defaults are exactly what Ozon documents: 50 rps globally plus the
62
+ // per-method rates from the spec (e.g. /v2/products/stocks — 80 per minute).
63
+ // Add limits you have measured yourself:
64
+ perPath: { '/v3/product/list': { limit: 20, intervalMs: 1_000 } },
65
+ maxSize: 5_000, // reject calls once the queue is this deep
66
+ waitTimeoutMs: 30_000, // reject calls that wait longer
67
+ hooks: {
68
+ onEnqueue: ({ size }) => metrics.gauge('ozon.queue', size),
69
+ onRateLimited: ({ path, until }) => log.warn({ path, until }, 'ozon 429'),
70
+ onCircuitOpen: ({ path, until }) => log.error({ path, until }, 'circle is open'),
71
+ },
72
+ }),
73
+ });
74
+ ```
75
+
76
+ Calls are served by priority (`priority` in the call options, higher first), then by
77
+ arrival. A path stuck at its own limit does not block the others. Backpressure surfaces
78
+ as a typed `OzonQueueError` with a `reason` of `queue-full`, `wait-timeout`, or
79
+ `aborted`.
80
+
81
+ The limiter is in-process. Several processes sharing one Client-Id each get their own
82
+ budget — for a limit that spans instances, implement the `OzonRateLimiter` interface
83
+ over shared storage:
84
+
85
+ ```ts
86
+ import type { OzonRateLimiter } from 'artefactby-ozon-seller-api';
87
+
88
+ const redisLimiter: OzonRateLimiter = {
89
+ async acquire({ path, priority, signal }) {
90
+ /* wait for a slot in Redis */
91
+ },
92
+ notify({ path, status, retryAfterMs, circuitOpen }) {
93
+ /* record the backoff so every instance sees it */
94
+ },
95
+ };
96
+ ```
97
+
98
+ ## Error handling
99
+
100
+ A non-2xx response throws an `OzonApiError`; transport failures (DNS, TLS, dropped
101
+ connections) propagate untouched — the package does not wrap them.
102
+
103
+ ```ts
104
+ import { isOzonApiError } from 'artefactby-ozon-seller-api';
105
+
106
+ try {
107
+ await client.request('/v1/product/import/prices', { prices });
108
+ } catch (error) {
109
+ if (isOzonApiError(error)) {
110
+ error.status; // HTTP status, e.g. 429
111
+ error.code; // Ozon's own error code, when the response carried one
112
+ error.retryAfterMs; // parsed Retry-After, when present
113
+ error.body; // response payload (object, or the raw text)
114
+ }
115
+ throw error;
116
+ }
117
+ ```
118
+
119
+ ## Options
120
+
121
+ ```ts
122
+ const client = new OzonClient({
123
+ clientId,
124
+ apiKey,
125
+ baseUrl: 'https://api-seller.ozon.ru', // default
126
+ fetch: customFetch, // default: the global fetch
127
+ headers: { 'User-Agent': 'my-app/1.0' }, // sent with every request
128
+ timeoutMs: 30_000, // off by default
129
+ });
130
+
131
+ await client.request('/v1/actions', undefined, {
132
+ signal: controller.signal,
133
+ timeoutMs: 5_000, // overrides the client-level timeout; 0 disables it
134
+ headers: { 'X-Request-Id': id },
135
+ priority: 10, // for the limiter: higher goes first
136
+ });
137
+ ```
138
+
139
+ The injectable `fetch` is the seam for tests: pass your own implementation to record
140
+ and replay traffic. The package ships no mocks of its own.
141
+
142
+ ## Requests beyond the types
143
+
144
+ `request()` covers the JSON operations. For everything else there is `requestRaw()` —
145
+ it returns the untouched `Response` and does not throw on a non-2xx status:
146
+
147
+ ```ts
148
+ // PDF with posting labels
149
+ const response = await client.requestRaw('/v2/posting/fbs/package-label', {
150
+ posting_number: ['0001-1'],
151
+ });
152
+ const pdf = await response.blob();
153
+
154
+ // The spec's single templated path needs an explicit method: with the guid
155
+ // substituted, the URL no longer matches the spec literal
156
+ const label = await client.requestRaw(`/v1/cargoes-label/file/${guid}`, undefined, {
157
+ method: 'GET',
158
+ });
159
+ ```
160
+
161
+ `multipart/form-data` (pass a `FormData`), binary bodies and `ReadableStream`s go
162
+ through as-is; anything else is serialized as JSON. A stream is never retried — it is
163
+ consumed by the first attempt that sends it.
164
+
165
+ ## Pagination is yours
166
+
167
+ The package deliberately stops at the transport line: one call is one logical API
168
+ request. Pagination loops, dataset assembly and batch splitting are your code, and the
169
+ typed core leaves them very little to do:
170
+
171
+ ```ts
172
+ const items = [];
173
+ let cursor = '';
174
+ do {
175
+ const page = await client.request('/v5/product/info/prices', {
176
+ cursor,
177
+ limit: 1000,
178
+ filter: { visibility: 'ALL' },
179
+ });
180
+ items.push(...(page.items ?? []));
181
+ cursor = page.cursor ?? '';
182
+ } while (cursor !== '');
183
+ ```
184
+
185
+ ## Known limitations
186
+
187
+ - The types are exactly as accurate as Ozon's spec. Where it diverges from reality,
188
+ `requestRaw()` is the way out.
189
+ - The spec is updated in the package manually — brand-new API methods may lag behind.
190
+ - The root entry's type declarations weigh about 3 MB (that is the entire spec); the
191
+ type checker's first pass will notice. The `/limiter` subpath is free of them.
192
+ - The built-in limiter does not share its budget across processes (see above).
193
+
194
+ ## License
195
+
196
+ [MIT](./LICENSE)
package/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # artefactby-ozon-seller-api
2
+
3
+ Неофициальный типизированный клиент [Ozon Seller API](https://docs.ozon.ru/api/seller/)
4
+ для TypeScript и JavaScript.
5
+
6
+ [README in English](./README.en.md)
7
+
8
+ ```ts
9
+ import { OzonClient } from 'artefactby-ozon-seller-api';
10
+
11
+ const client = new OzonClient({
12
+ clientId: process.env.OZON_CLIENT_ID!,
13
+ apiKey: process.env.OZON_API_KEY!,
14
+ });
15
+
16
+ // `items` типизирован из литерала пути — без generics и приведения типов
17
+ const { items } = await client.request('/v5/product/info/prices', {
18
+ cursor: '',
19
+ limit: 100,
20
+ filter: { visibility: 'ALL' },
21
+ });
22
+ ```
23
+
24
+ - **Покрыт весь API, а не выборка методов.** Типы сгенерированы из официальной
25
+ OpenAPI-спеки (версия API 2.1): 458 операций, 2083 схемы. Любой путь спеки
26
+ вызывается одинаково, тело запроса и ответ выводятся из литерала пути.
27
+ - **Ноль зависимостей в рантайме.** Только нативный `fetch`. Node.js >= 18, CJS и ESM.
28
+ - **Встроенный rate limiter со знанием лимитов Ozon** — 50 rps на Client-Id плюс
29
+ пометодные лимиты из спеки — отдельным сабпатом: не подключили — не платите ничего.
30
+ - **Осторожные ретраи.** Почти все операции Ozon — POST, поэтому повторяются только
31
+ запросы, отклонённые с 429 или «Circle is open»: их API гарантированно не обработал.
32
+ «Повторить на всякий случай» клиент не умеет намеренно.
33
+
34
+ Статус: 0.x — публичный API может меняться между минорными версиями. Пакет не
35
+ аффилирован с Ozon.
36
+
37
+ ## Установка
38
+
39
+ ```bash
40
+ npm install artefactby-ozon-seller-api
41
+ ```
42
+
43
+ Один экземпляр клиента привязан к одному Client-Id. Пакет никогда не читает ключи из
44
+ окружения сам — передавайте их явно в конструктор.
45
+
46
+ ## Ограничение частоты запросов
47
+
48
+ Ozon отвечает 429 с `Retry-After` при превышении лимита и блокирует метод на несколько
49
+ минут («Circle is open») при шквале запросов. Клиент сам выдерживает `Retry-After` и
50
+ повторяет отклонённый запрос до `maxRetries` раз (по умолчанию 2). Чтобы не упираться
51
+ в лимиты вовсе, подключите встроенный лимитер — он выстраивает вызовы заранее:
52
+
53
+ ```ts
54
+ import { OzonClient } from 'artefactby-ozon-seller-api';
55
+ import { TokenBucketLimiter } from 'artefactby-ozon-seller-api/limiter';
56
+
57
+ const client = new OzonClient({
58
+ clientId,
59
+ apiKey,
60
+ limiter: new TokenBucketLimiter({
61
+ // Дефолты — ровно то, что документирует Ozon: 50 rps глобально плюс
62
+ // пометодные лимиты из спеки (например, /v2/products/stocks — 80 в минуту).
63
+ // Сюда можно добавить лимиты, измеренные вами самостоятельно:
64
+ perPath: { '/v3/product/list': { limit: 20, intervalMs: 1_000 } },
65
+ maxSize: 5_000, // отклонять вызовы, когда очередь глубже
66
+ waitTimeoutMs: 30_000, // отклонять вызовы, ждущие дольше
67
+ hooks: {
68
+ onEnqueue: ({ size }) => metrics.gauge('ozon.queue', size),
69
+ onRateLimited: ({ path, until }) => log.warn({ path, until }, 'ozon 429'),
70
+ onCircuitOpen: ({ path, until }) => log.error({ path, until }, 'circle is open'),
71
+ },
72
+ }),
73
+ });
74
+ ```
75
+
76
+ Вызовы обслуживаются по приоритету (`priority` в опциях вызова, выше — раньше), внутри
77
+ приоритета — по очереди. Путь, упёршийся в собственный лимит, не блокирует остальные.
78
+ Backpressure приходит типизированной ошибкой `OzonQueueError` с `reason`:
79
+ `queue-full`, `wait-timeout` или `aborted`.
80
+
81
+ Лимитер работает в пределах одного процесса. Несколько процессов с одним Client-Id
82
+ получат каждый свой бюджет — для общего лимита реализуйте интерфейс `OzonRateLimiter`
83
+ поверх разделяемого хранилища:
84
+
85
+ ```ts
86
+ import type { OzonRateLimiter } from 'artefactby-ozon-seller-api';
87
+
88
+ const redisLimiter: OzonRateLimiter = {
89
+ async acquire({ path, priority, signal }) {
90
+ /* дождаться слота в Redis */
91
+ },
92
+ notify({ path, status, retryAfterMs, circuitOpen }) {
93
+ /* записать backoff, чтобы его увидели все инстансы */
94
+ },
95
+ };
96
+ ```
97
+
98
+ ## Обработка ошибок
99
+
100
+ Не-2xx ответ — это `OzonApiError`; транспортные сбои (DNS, TLS, обрыв) пролетают как
101
+ есть, их пакет не заворачивает.
102
+
103
+ ```ts
104
+ import { isOzonApiError } from 'artefactby-ozon-seller-api';
105
+
106
+ try {
107
+ await client.request('/v1/product/import/prices', { prices });
108
+ } catch (error) {
109
+ if (isOzonApiError(error)) {
110
+ error.status; // HTTP-статус, например 429
111
+ error.code; // код ошибки Ozon, если был в ответе
112
+ error.retryAfterMs; // разобранный Retry-After, если был
113
+ error.body; // тело ответа (объект или сырой текст)
114
+ }
115
+ throw error;
116
+ }
117
+ ```
118
+
119
+ ## Опции
120
+
121
+ ```ts
122
+ const client = new OzonClient({
123
+ clientId,
124
+ apiKey,
125
+ baseUrl: 'https://api-seller.ozon.ru', // по умолчанию
126
+ fetch: customFetch, // по умолчанию — глобальный fetch
127
+ headers: { 'User-Agent': 'my-app/1.0' }, // добавляются к каждому запросу
128
+ timeoutMs: 30_000, // выключен по умолчанию
129
+ });
130
+
131
+ await client.request('/v1/actions', undefined, {
132
+ signal: controller.signal,
133
+ timeoutMs: 5_000, // важнее клиентского; 0 отключает
134
+ headers: { 'X-Request-Id': id },
135
+ priority: 10, // для лимитера: выше — раньше
136
+ });
137
+ ```
138
+
139
+ Инжектируемый `fetch` — это шов для тестов: подставьте свою реализацию для записи и
140
+ воспроизведения трафика. Собственных моков пакет не содержит.
141
+
142
+ ## Запросы мимо типов
143
+
144
+ `request()` покрывает JSON-операции. Для остального есть `requestRaw()` — он возвращает
145
+ нетронутый `Response` и не бросает на не-2xx статусе:
146
+
147
+ ```ts
148
+ // PDF с этикетками отправлений
149
+ const response = await client.requestRaw('/v2/posting/fbs/package-label', {
150
+ posting_number: ['0001-1'],
151
+ });
152
+ const pdf = await response.blob();
153
+
154
+ // Единственный путь спеки с параметром в URL требует явного метода:
155
+ // подставленный guid уже не совпадает с литералом из спеки
156
+ const label = await client.requestRaw(`/v1/cargoes-label/file/${guid}`, undefined, {
157
+ method: 'GET',
158
+ });
159
+ ```
160
+
161
+ `multipart/form-data` (передайте `FormData`), бинарные тела и `ReadableStream`
162
+ уходят как есть; всё остальное сериализуется в JSON. Стрим не ретраится никогда —
163
+ его потребляет первая же попытка отправки.
164
+
165
+ ## Пагинация — на вашей стороне
166
+
167
+ Пакет сознательно заканчивается на границе транспорта: один вызов — один логический
168
+ запрос к API. Циклы пагинации, сборка датасетов, нарезка на батчи — это ваш код,
169
+ которому типизированное ядро оставляет совсем немного работы:
170
+
171
+ ```ts
172
+ const items = [];
173
+ let cursor = '';
174
+ do {
175
+ const page = await client.request('/v5/product/info/prices', {
176
+ cursor,
177
+ limit: 1000,
178
+ filter: { visibility: 'ALL' },
179
+ });
180
+ items.push(...(page.items ?? []));
181
+ cursor = page.cursor ?? '';
182
+ } while (cursor !== '');
183
+ ```
184
+
185
+ ## Известные ограничения
186
+
187
+ - Типы ровно настолько точны, насколько точна спека Ozon. Там, где она расходится с
188
+ реальностью, выручает `requestRaw()`.
189
+ - Спека обновляется в пакете вручную — новые методы API могут появляться с задержкой.
190
+ - Декларации типов корневого входа весят около 3 МБ (это вся спека): первый проход
191
+ тайпчекера их заметит. Сабпат `/limiter` от этого свободен.
192
+ - Встроенный лимитер не разделяет бюджет между процессами (см. выше).
193
+
194
+ ## Лицензия
195
+
196
+ [MIT](./LICENSE)
@@ -0,0 +1 @@
1
+ export * from './index.js';