@pimia/sdk 0.5.0 → 0.7.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/README.md +50 -0
- package/dist/api.d.ts +9926 -5013
- package/dist/api.js +4 -0
- package/dist/client.d.ts +244 -145
- package/dist/client.js +160 -4
- package/dist/index.d.ts +11 -1
- package/dist/index.js +11 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -127,6 +127,32 @@ export class PimiaClient {
|
|
|
127
127
|
delete(path, options) {
|
|
128
128
|
return this.request(path, { ...options, method: 'DELETE' });
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Descarga un fichero de la API y te lo da como `Blob`.
|
|
132
|
+
*
|
|
133
|
+
* Son dos operaciones: el membrete de una plantilla
|
|
134
|
+
* (`GET /invoice-templates/{id}/letterhead`) y el documento escaneado de una
|
|
135
|
+
* factura recibida (`GET /received-invoices/{id}/show/document`).
|
|
136
|
+
*
|
|
137
|
+
* Existe porque `get()` **corrompe un binario sin decirlo**: lee la
|
|
138
|
+
* respuesta con `response.text()`, y un PDF pasado por ahí llega entero de
|
|
139
|
+
* tamaño y no se abre. Ese es el peor final posible para una descarga, así
|
|
140
|
+
* que la forma correcta tiene nombre propio en vez de ser una bandera que
|
|
141
|
+
* hay que acordarse de poner.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* const pdf = await client.download(`/received-invoices/${id}/show/document`)
|
|
145
|
+
* const url = URL.createObjectURL(pdf)
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
download(path, query, options) {
|
|
149
|
+
return this.request(path, {
|
|
150
|
+
...options,
|
|
151
|
+
method: 'GET',
|
|
152
|
+
query,
|
|
153
|
+
responseType: 'blob',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
130
156
|
/**
|
|
131
157
|
* Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
|
|
132
158
|
* `/invoices` y `/api/v1/invoices` son lo mismo.
|
|
@@ -152,6 +178,13 @@ export class PimiaClient {
|
|
|
152
178
|
* ```
|
|
153
179
|
*/
|
|
154
180
|
async requestWithMeta(path, options = {}) {
|
|
181
|
+
/* El cuerpo se clasifica UNA vez, fuera del bucle: lo que se manda no
|
|
182
|
+
cambia entre el intento y su reintento, y decidirlo dentro invitaría a
|
|
183
|
+
que algún día dejaran de coincidir. */
|
|
184
|
+
const cuerpoNativo = esCuerpoNativo(options.body);
|
|
185
|
+
if (cuerpoNativo) {
|
|
186
|
+
exigirSinContentType(options.body, { ...this.extraHeaders, ...options.headers });
|
|
187
|
+
}
|
|
155
188
|
let tokens = await this.currentTokens();
|
|
156
189
|
if (isExpired(tokens, this.skew)) {
|
|
157
190
|
tokens = await this.refreshTokens(tokens);
|
|
@@ -162,8 +195,17 @@ export class PimiaClient {
|
|
|
162
195
|
const response = await this.doFetch(this.urlFor(path, options.query), {
|
|
163
196
|
method: options.method ?? 'GET',
|
|
164
197
|
headers: {
|
|
165
|
-
|
|
166
|
-
|
|
198
|
+
/* Una descarga no pide JSON: si se dejara `application/json` fijo, un
|
|
199
|
+
servidor que negocie el tipo tendría derecho a contestar 406 —o a
|
|
200
|
+
mandar un JSON de error donde se esperaba el fichero. */
|
|
201
|
+
accept: options.responseType === 'blob' ? '*/*' : 'application/json',
|
|
202
|
+
/* Un cuerpo nativo trae su propio tipo: el runtime le pone
|
|
203
|
+
`multipart/form-data` CON su `boundary`, o el de un `Blob`, o
|
|
204
|
+
`application/x-www-form-urlencoded`. Escribirlo aquí a mano se lo
|
|
205
|
+
quitaría, y sin `boundary` el servidor no puede parsear nada. */
|
|
206
|
+
...(options.body === undefined || cuerpoNativo
|
|
207
|
+
? {}
|
|
208
|
+
: { 'content-type': 'application/json' }),
|
|
167
209
|
...this.extraHeaders,
|
|
168
210
|
...options.headers,
|
|
169
211
|
// Después de `options.headers` para que la opción con nombre mande
|
|
@@ -174,13 +216,23 @@ export class PimiaClient {
|
|
|
174
216
|
: { 'idempotency-key': options.idempotencyKey }),
|
|
175
217
|
authorization: `Bearer ${tokens.accessToken}`,
|
|
176
218
|
},
|
|
177
|
-
body: options.body === undefined
|
|
219
|
+
body: options.body === undefined
|
|
220
|
+
? undefined
|
|
221
|
+
: cuerpoNativo
|
|
222
|
+
? options.body
|
|
223
|
+
: JSON.stringify(options.body),
|
|
178
224
|
signal: options.signal,
|
|
179
225
|
});
|
|
180
226
|
this.captureRateLimit(response);
|
|
181
227
|
if (response.ok) {
|
|
182
228
|
return {
|
|
183
|
-
|
|
229
|
+
/* Una descarga se devuelve como `Blob` SIN pasar por `parseBody`,
|
|
230
|
+
que hace `response.text()`: un PDF leído como texto se corrompe en
|
|
231
|
+
la primera secuencia que no sea UTF-8 válido, y lo hace en
|
|
232
|
+
silencio — el fichero «llega» y no se abre. */
|
|
233
|
+
data: (options.responseType === 'blob'
|
|
234
|
+
? await response.blob()
|
|
235
|
+
: await parseBody(response)),
|
|
184
236
|
meta: {
|
|
185
237
|
status: response.status,
|
|
186
238
|
// Presente solo cuando Pimia reproduce; su ausencia significa
|
|
@@ -287,6 +339,110 @@ export class PimiaClient {
|
|
|
287
339
|
return Math.min(base, this.maxRetryDelayMs);
|
|
288
340
|
}
|
|
289
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* ¿Es un cuerpo que el runtime serializa por su cuenta?
|
|
344
|
+
*
|
|
345
|
+
* Los cinco de la lista tienen dos cosas en común, y las dos importan: `fetch`
|
|
346
|
+
* sabe convertirlos y **se pueden releer**. Lo segundo es lo que decide quién
|
|
347
|
+
* entra: este cliente reintenta ante un 401 (después de refrescar) y ante un
|
|
348
|
+
* 429, así que un cuerpo de un solo uso —un `ReadableStream`— reventaría en el
|
|
349
|
+
* reintento con un «body already used» que no se parece en nada a su causa.
|
|
350
|
+
*
|
|
351
|
+
* Los `typeof … !== 'undefined'` no son celo: este paquete corre en Node y en
|
|
352
|
+
* el navegador, y aunque Node 20 los trae todos, un runtime recortado que no
|
|
353
|
+
* tenga `FormData` debe fallar en el `instanceof`, no al evaluarlo.
|
|
354
|
+
*/
|
|
355
|
+
function esCuerpoNativo(body) {
|
|
356
|
+
if (body === undefined || body === null)
|
|
357
|
+
return false;
|
|
358
|
+
return ((typeof FormData !== 'undefined' && body instanceof FormData) ||
|
|
359
|
+
(typeof Blob !== 'undefined' && body instanceof Blob) ||
|
|
360
|
+
(typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) ||
|
|
361
|
+
body instanceof ArrayBuffer ||
|
|
362
|
+
ArrayBuffer.isView(body));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Un `FormData` con un `content-type` puesto a mano **no se manda**: se avisa.
|
|
366
|
+
*
|
|
367
|
+
* La cabecera de un multipart lleva el `boundary` que separa las partes, y lo
|
|
368
|
+
* genera el runtime al serializar. Escribir `content-type:
|
|
369
|
+
* multipart/form-data` a mano se lo quita, y entonces el servidor recibe un
|
|
370
|
+
* cuerpo que no puede parsear: contesta un 422 sobre un campo obligatorio que
|
|
371
|
+
* el cliente **sí mandó**, y el rastro no lleva a ninguna parte.
|
|
372
|
+
*
|
|
373
|
+
* Es un error de quien llama, no de la API, así que se lanza aquí y no se
|
|
374
|
+
* intenta arreglar por su cuenta: quitarle la cabecera en silencio dejaría en
|
|
375
|
+
* pie la creencia de que hacía falta.
|
|
376
|
+
*/
|
|
377
|
+
function exigirSinContentType(body, headers) {
|
|
378
|
+
if (typeof FormData === 'undefined' || !(body instanceof FormData))
|
|
379
|
+
return;
|
|
380
|
+
const puesta = Object.keys(headers).find((k) => k.toLowerCase() === 'content-type');
|
|
381
|
+
if (puesta === undefined)
|
|
382
|
+
return;
|
|
383
|
+
throw new TypeError('No le pongas `content-type` a un cuerpo FormData: el runtime escribe el suyo ' +
|
|
384
|
+
'con el `boundary` que separa las partes, y una cabecera a mano se lo quita ' +
|
|
385
|
+
`(el servidor respondería 422 sobre un campo que sí mandaste). Quita \`${puesta}\` ` +
|
|
386
|
+
'de las cabeceras de esta petición.');
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Arma el `FormData` de una operación multipart con las conversiones que el
|
|
390
|
+
* servidor de Pimia espera, que **no** son las que hace `FormData` sola.
|
|
391
|
+
*
|
|
392
|
+
* Tres reglas, y las tres salen del contrato, no de la costumbre:
|
|
393
|
+
*
|
|
394
|
+
* - **Los booleanos viajan como `1` y `0`.** Lo dice el propio spec en
|
|
395
|
+
* `ExpenseRequest.is_attachment_receipt_removed`: «en `multipart/form-data`
|
|
396
|
+
* viaja como `1` o `0`». Un `String(false)` daría `"false"`, que PHP lee
|
|
397
|
+
* como verdadero.
|
|
398
|
+
* - **Los objetos y arrays viajan como JSON en una cadena.** También del
|
|
399
|
+
* spec, en `ExpenseRequest.customFields`: «viaja como cadena JSON:
|
|
400
|
+
* `[{"id":3,"value":"REF-42"}]`».
|
|
401
|
+
* - **`null` y `undefined` se omiten**, en vez de mandar `"null"`. Un campo
|
|
402
|
+
* ausente es un campo ausente; la cadena `"null"` es un valor.
|
|
403
|
+
*
|
|
404
|
+
* Un `Blob` o un `File` se añaden tal cual. Con un `File` el runtime manda ya
|
|
405
|
+
* su nombre; con un `Blob` suelto se puede dar uno pasando `[blob, 'x.pdf']`,
|
|
406
|
+
* que es la forma que el tercer argumento de `append` admite.
|
|
407
|
+
*
|
|
408
|
+
* ```ts
|
|
409
|
+
* await client.post('/expenses', toFormData({
|
|
410
|
+
* expense_date: '2026-08-24',
|
|
411
|
+
* expense_category_id: 3,
|
|
412
|
+
* amount: 12100,
|
|
413
|
+
* attachment_receipt: ficheroPdf,
|
|
414
|
+
* customFields: [{ id: 3, value: 'REF-42' }],
|
|
415
|
+
* }))
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
export function toFormData(fields) {
|
|
419
|
+
const form = new FormData();
|
|
420
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
421
|
+
if (value === undefined || value === null)
|
|
422
|
+
continue;
|
|
423
|
+
if (Array.isArray(value) && value.length === 2 && esBlob(value[0]) && typeof value[1] === 'string') {
|
|
424
|
+
form.append(name, value[0], value[1]);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (esBlob(value)) {
|
|
428
|
+
form.append(name, value);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (typeof value === 'boolean') {
|
|
432
|
+
form.append(name, value ? '1' : '0');
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (typeof value === 'object') {
|
|
436
|
+
form.append(name, JSON.stringify(value));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
form.append(name, String(value));
|
|
440
|
+
}
|
|
441
|
+
return form;
|
|
442
|
+
}
|
|
443
|
+
function esBlob(value) {
|
|
444
|
+
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
445
|
+
}
|
|
290
446
|
function retryAfterSeconds(response) {
|
|
291
447
|
const header = response.headers.get('retry-after');
|
|
292
448
|
if (header === null)
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* por README.md; el contrato completo de endpoints está en el OpenAPI del que
|
|
6
6
|
* salen los tipos de `./api`.
|
|
7
7
|
*/
|
|
8
|
-
export { PimiaClient } from './client.js';
|
|
8
|
+
export { PimiaClient, toFormData } from './client.js';
|
|
9
9
|
export type { CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, PimiaClientOptions, RateLimit, ReadOptions, RequestOptions, ResourceEnvelope, ResponseMeta, ResponseWithMeta, WriteOptions, } from './client.js';
|
|
10
10
|
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
11
11
|
export type { AuthorizationServerMetadata, AuthorizeUrlOptions, OAuthConfig, PkceChallenge, } from './oauth.js';
|
|
@@ -35,6 +35,16 @@ export declare const SCOPES: {
|
|
|
35
35
|
readonly agendaRead: "agenda:read";
|
|
36
36
|
readonly agendaWrite: "agenda:write";
|
|
37
37
|
readonly reportsRead: "reports:read";
|
|
38
|
+
/** Leer la configuración de la empresa (impuestos, preferencias, series). */
|
|
39
|
+
readonly settingsRead: "settings:read";
|
|
40
|
+
/** Ver la tienda de módulos y qué tiene contratado el tenant. */
|
|
41
|
+
readonly storeRead: "store:read";
|
|
42
|
+
/** Leer la gestión de personal: empleados, ausencias, fichajes, calendarios. */
|
|
43
|
+
readonly hrRead: "hr:read";
|
|
44
|
+
/** Gestionar el personal: altas, ausencias, correcciones de fichaje, horarios. */
|
|
45
|
+
readonly hrWrite: "hr:write";
|
|
46
|
+
/** Gestionar los avisos (webhooks) que recibe tu app. */
|
|
47
|
+
readonly webhooksWrite: "webhooks:write";
|
|
38
48
|
/**
|
|
39
49
|
* Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
|
|
40
50
|
* `approvalsSubmit` es un alias del mismo permiso, aceptado por el
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* por README.md; el contrato completo de endpoints está en el OpenAPI del que
|
|
6
6
|
* salen los tipos de `./api`.
|
|
7
7
|
*/
|
|
8
|
-
export { PimiaClient } from './client.js';
|
|
8
|
+
export { PimiaClient, toFormData } from './client.js';
|
|
9
9
|
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
10
10
|
export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
11
11
|
export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
@@ -31,6 +31,16 @@ export const SCOPES = {
|
|
|
31
31
|
agendaRead: 'agenda:read',
|
|
32
32
|
agendaWrite: 'agenda:write',
|
|
33
33
|
reportsRead: 'reports:read',
|
|
34
|
+
/** Leer la configuración de la empresa (impuestos, preferencias, series). */
|
|
35
|
+
settingsRead: 'settings:read',
|
|
36
|
+
/** Ver la tienda de módulos y qué tiene contratado el tenant. */
|
|
37
|
+
storeRead: 'store:read',
|
|
38
|
+
/** Leer la gestión de personal: empleados, ausencias, fichajes, calendarios. */
|
|
39
|
+
hrRead: 'hr:read',
|
|
40
|
+
/** Gestionar el personal: altas, ausencias, correcciones de fichaje, horarios. */
|
|
41
|
+
hrWrite: 'hr:write',
|
|
42
|
+
/** Gestionar los avisos (webhooks) que recibe tu app. */
|
|
43
|
+
webhooksWrite: 'webhooks:write',
|
|
34
44
|
/**
|
|
35
45
|
* Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
|
|
36
46
|
* `approvalsSubmit` es un alias del mismo permiso, aceptado por el
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimia/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Cliente TypeScript de la API de Pimia para apps de partner: OAuth con PKCE, rotación de refresh persistida, reintentos de rate limit y tipos generados del OpenAPI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Pimia (https://pimia.es)",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
-
"generate:types": "
|
|
43
|
+
"generate:types": "node scripts/generate-types.mjs",
|
|
44
44
|
"test": "node --test test/*.test.js",
|
|
45
45
|
"prepublishOnly": "npm run build"
|
|
46
46
|
},
|