@pimia/sdk 0.6.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 +13 -9
- package/dist/api.js +4 -0
- package/dist/client.d.ts +86 -1
- package/dist/client.js +160 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -92,6 +92,56 @@ if (meta.idempotentReplay) {
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Subir un fichero
|
|
96
|
+
|
|
97
|
+
Diez operaciones de la API son `multipart/form-data`: el justificante de un
|
|
98
|
+
gasto, el documento de una factura recibida, un extracto bancario, el membrete
|
|
99
|
+
de una plantilla, el certificado de firma, el avatar. Para ésas pásale un
|
|
100
|
+
`FormData` y el cliente lo manda tal cual — **no le pongas `content-type`**: el
|
|
101
|
+
runtime escribe el suyo con el `boundary` que separa las partes, y una cabecera
|
|
102
|
+
puesta a mano se lo quita (el cliente lo rechaza antes de salir, con un aviso
|
|
103
|
+
que lo explica).
|
|
104
|
+
|
|
105
|
+
`toFormData` hace las tres conversiones que el servidor espera y que `FormData`
|
|
106
|
+
sola no hace: los booleanos como `1`/`0`, los objetos y arrays como cadena
|
|
107
|
+
JSON, y los `null` omitidos en vez de mandados como la cadena `"null"`.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { toFormData } from '@pimia/sdk'
|
|
111
|
+
|
|
112
|
+
// Un gasto con su justificante en PDF, de una sola llamada.
|
|
113
|
+
await client.post('/expenses', toFormData({
|
|
114
|
+
expense_date: '2026-08-24',
|
|
115
|
+
expense_category_id: 3,
|
|
116
|
+
amount: 12100, // céntimos, como todo importe
|
|
117
|
+
attachment_receipt: ficheroDelInput, // un File del navegador
|
|
118
|
+
customFields: [{ id: 3, value: 'REF-42' }],
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
// El documento de una factura recibida, con un Blob al que le das nombre.
|
|
122
|
+
const form = new FormData()
|
|
123
|
+
form.append('document', blobPdf, 'factura-proveedor.pdf')
|
|
124
|
+
await client.post(`/received-invoices/${id}/upload/document`, form)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Los campos de fichero salen tipados como `Blob` en `@pimia/sdk/api`, así que un
|
|
128
|
+
`File` del navegador encaja sin ceremonia.
|
|
129
|
+
|
|
130
|
+
⚠️ Lo que **no** puedes pasar es un `ReadableStream`: el cliente reintenta ante
|
|
131
|
+
un 401 y ante un 429, y un cuerpo de un solo uso no se puede volver a mandar.
|
|
132
|
+
|
|
133
|
+
## Descargar un fichero
|
|
134
|
+
|
|
135
|
+
Para las dos operaciones que devuelven un binario, `download`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const pdf = await client.download(`/received-invoices/${id}/show/document`)
|
|
139
|
+
const url = URL.createObjectURL(pdf)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
⚠️ **No uses `get()` para esto.** Lee la respuesta con `response.text()`, así
|
|
143
|
+
que un PDF llega entero de tamaño y no se abre — sin ningún error que mirar.
|
|
144
|
+
|
|
95
145
|
## Recibir webhooks
|
|
96
146
|
|
|
97
147
|
`verifyWebhook` comprueba la firma `PIMIA-WEBHOOK-v1` y te devuelve el evento
|
package/dist/api.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* This file was auto-generated by openapi-typescript.
|
|
3
3
|
* Do not make direct changes to the file.
|
|
4
|
+
*
|
|
5
|
+
* Generado con `npm run generate:types` (scripts/generate-types.mjs), que
|
|
6
|
+
* añade UN transform al generador: `format: binary` sale como `Blob` y no
|
|
7
|
+
* como `string`. El porqué, en ese fichero.
|
|
4
8
|
*/
|
|
5
9
|
export interface paths {
|
|
6
10
|
"/absences/balance": {
|
|
@@ -4043,7 +4047,7 @@ export interface components {
|
|
|
4043
4047
|
* Format: binary
|
|
4044
4048
|
* @description Maximum file size: 20000 kilobytes.
|
|
4045
4049
|
*/
|
|
4046
|
-
admin_avatar?:
|
|
4050
|
+
admin_avatar?: Blob | null;
|
|
4047
4051
|
avatar?: string | null;
|
|
4048
4052
|
};
|
|
4049
4053
|
/** BankAccount */
|
|
@@ -5067,7 +5071,7 @@ export interface components {
|
|
|
5067
5071
|
* Format: binary
|
|
5068
5072
|
* @description Maximum file size: 20000 kilobytes.
|
|
5069
5073
|
*/
|
|
5070
|
-
attachment_receipt?:
|
|
5074
|
+
attachment_receipt?: Blob | null;
|
|
5071
5075
|
/** @description Valores de campo personalizado del recurso. `id` es el de la definición, que se descubre en `GET /custom-fields` (catálogo `meta`, legible con cualquier token); las definiciones las crea el dueño del tenant desde su panel. Se devuelven en la clave `fields` del recurso. En `multipart/form-data` viaja como cadena JSON: `[{"id":3,"value":"REF-42"}]`. */
|
|
5072
5076
|
customFields?: string;
|
|
5073
5077
|
/** @description Borra el recibo adjunto del gasto. Solo surte efecto en la actualización; en `multipart/form-data` viaja como `1` o `0`. */
|
|
@@ -7778,7 +7782,7 @@ export interface operations {
|
|
|
7778
7782
|
* Format: binary
|
|
7779
7783
|
* @description Maximum file size: 10240 kilobytes.
|
|
7780
7784
|
*/
|
|
7781
|
-
file:
|
|
7785
|
+
file: Blob;
|
|
7782
7786
|
bank_account_id: number;
|
|
7783
7787
|
/** @enum {string} */
|
|
7784
7788
|
format: "norma43" | "csv" | "auto";
|
|
@@ -10925,7 +10929,7 @@ export interface operations {
|
|
|
10925
10929
|
[name: string]: unknown;
|
|
10926
10930
|
};
|
|
10927
10931
|
content: {
|
|
10928
|
-
"application/octet-stream":
|
|
10932
|
+
"application/octet-stream": Blob;
|
|
10929
10933
|
};
|
|
10930
10934
|
};
|
|
10931
10935
|
404: components["responses"]["ModelNotFoundException"];
|
|
@@ -10948,7 +10952,7 @@ export interface operations {
|
|
|
10948
10952
|
* Format: binary
|
|
10949
10953
|
* @description Maximum file size: 5120 kilobytes.
|
|
10950
10954
|
*/
|
|
10951
|
-
letterhead:
|
|
10955
|
+
letterhead: Blob;
|
|
10952
10956
|
};
|
|
10953
10957
|
};
|
|
10954
10958
|
};
|
|
@@ -11522,7 +11526,7 @@ export interface operations {
|
|
|
11522
11526
|
* Format: binary
|
|
11523
11527
|
* @description Maximum file size: 5120 kilobytes.
|
|
11524
11528
|
*/
|
|
11525
|
-
file:
|
|
11529
|
+
file: Blob;
|
|
11526
11530
|
};
|
|
11527
11531
|
};
|
|
11528
11532
|
};
|
|
@@ -13044,7 +13048,7 @@ export interface operations {
|
|
|
13044
13048
|
* Format: binary
|
|
13045
13049
|
* @description Maximum file size: 10240 kilobytes.
|
|
13046
13050
|
*/
|
|
13047
|
-
certificate:
|
|
13051
|
+
certificate: Blob;
|
|
13048
13052
|
password: string;
|
|
13049
13053
|
};
|
|
13050
13054
|
};
|
|
@@ -13279,7 +13283,7 @@ export interface operations {
|
|
|
13279
13283
|
[name: string]: unknown;
|
|
13280
13284
|
};
|
|
13281
13285
|
content: {
|
|
13282
|
-
"application/octet-stream":
|
|
13286
|
+
"application/octet-stream": Blob;
|
|
13283
13287
|
};
|
|
13284
13288
|
};
|
|
13285
13289
|
404: components["responses"]["ModelNotFoundException"];
|
|
@@ -13302,7 +13306,7 @@ export interface operations {
|
|
|
13302
13306
|
* Format: binary
|
|
13303
13307
|
* @description Maximum file size: 10240 kilobytes.
|
|
13304
13308
|
*/
|
|
13305
|
-
document:
|
|
13309
|
+
document: Blob;
|
|
13306
13310
|
};
|
|
13307
13311
|
};
|
|
13308
13312
|
};
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* This file was auto-generated by openapi-typescript.
|
|
3
3
|
* Do not make direct changes to the file.
|
|
4
|
+
*
|
|
5
|
+
* Generado con `npm run generate:types` (scripts/generate-types.mjs), que
|
|
6
|
+
* añade UN transform al generador: `format: binary` sale como `Blob` y no
|
|
7
|
+
* como `string`. El porqué, en ese fichero.
|
|
4
8
|
*/
|
|
5
9
|
export {};
|
package/dist/client.d.ts
CHANGED
|
@@ -64,8 +64,43 @@ export interface RequestOptions {
|
|
|
64
64
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
65
65
|
/** Query string. Los `undefined`/`null` se omiten; los arrays se repiten. */
|
|
66
66
|
query?: Record<string, string | number | boolean | undefined | null | Array<string | number>>;
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* El cuerpo de la petición.
|
|
69
|
+
*
|
|
70
|
+
* Por defecto se manda como **JSON**, que es lo que pide casi toda la API.
|
|
71
|
+
*
|
|
72
|
+
* Diez operaciones del contrato son `multipart/form-data` —el justificante
|
|
73
|
+
* de un gasto, el documento de una factura recibida, importar un extracto
|
|
74
|
+
* bancario, el membrete de una plantilla, el certificado de firma…—, y para
|
|
75
|
+
* ésas se pasa un {@link FormData}: el cliente lo manda **tal cual** y **no
|
|
76
|
+
* le pone `content-type`**, para que el runtime escriba el suyo con su
|
|
77
|
+
* `boundary`. {@link toFormData} lo arma con las conversiones que el
|
|
78
|
+
* servidor espera.
|
|
79
|
+
*
|
|
80
|
+
* También pasan sin tocar `Blob`, `URLSearchParams`, `ArrayBuffer` y las
|
|
81
|
+
* vistas de `ArrayBuffer`.
|
|
82
|
+
*
|
|
83
|
+
* ⛔ Un `ReadableStream` **no**, y es a propósito: este cliente reintenta
|
|
84
|
+
* ante un 401 (tras refrescar) y ante un 429, y un stream ya consumido no se
|
|
85
|
+
* puede volver a mandar — el reintento fallaría con un error que no se
|
|
86
|
+
* parece en nada a su causa. Los cinco de arriba se pueden releer.
|
|
87
|
+
*/
|
|
68
88
|
body?: unknown;
|
|
89
|
+
/**
|
|
90
|
+
* Cómo leer una respuesta **correcta**.
|
|
91
|
+
*
|
|
92
|
+
* `'json'` (el defecto) es lo de siempre. `'blob'` es para las dos
|
|
93
|
+
* operaciones que devuelven un fichero (`application/octet-stream`):
|
|
94
|
+
* descargar el membrete de una plantilla y el documento escaneado de una
|
|
95
|
+
* factura recibida.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ Sin esto, un PDF se lee con `response.text()` y **se corrompe en
|
|
98
|
+
* silencio**: el fichero «llega», pesa lo suyo y no se abre.
|
|
99
|
+
*
|
|
100
|
+
* Los errores se siguen leyendo como JSON aunque pidas `'blob'` — cuando la
|
|
101
|
+
* API falla contesta su sobre de error, no el fichero.
|
|
102
|
+
*/
|
|
103
|
+
responseType?: 'json' | 'blob';
|
|
69
104
|
headers?: Record<string, string>;
|
|
70
105
|
signal?: AbortSignal;
|
|
71
106
|
/**
|
|
@@ -531,6 +566,25 @@ export declare class PimiaClient {
|
|
|
531
566
|
put<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
|
|
532
567
|
patch<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
|
|
533
568
|
delete<T = unknown>(path: string, options?: ReadOptions): Promise<T>;
|
|
569
|
+
/**
|
|
570
|
+
* Descarga un fichero de la API y te lo da como `Blob`.
|
|
571
|
+
*
|
|
572
|
+
* Son dos operaciones: el membrete de una plantilla
|
|
573
|
+
* (`GET /invoice-templates/{id}/letterhead`) y el documento escaneado de una
|
|
574
|
+
* factura recibida (`GET /received-invoices/{id}/show/document`).
|
|
575
|
+
*
|
|
576
|
+
* Existe porque `get()` **corrompe un binario sin decirlo**: lee la
|
|
577
|
+
* respuesta con `response.text()`, y un PDF pasado por ahí llega entero de
|
|
578
|
+
* tamaño y no se abre. Ese es el peor final posible para una descarga, así
|
|
579
|
+
* que la forma correcta tiene nombre propio en vez de ser una bandera que
|
|
580
|
+
* hay que acordarse de poner.
|
|
581
|
+
*
|
|
582
|
+
* ```ts
|
|
583
|
+
* const pdf = await client.download(`/received-invoices/${id}/show/document`)
|
|
584
|
+
* const url = URL.createObjectURL(pdf)
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
download(path: string, query?: RequestOptions['query'], options?: ReadOptions): Promise<Blob>;
|
|
534
588
|
/**
|
|
535
589
|
* Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
|
|
536
590
|
* `/invoices` y `/api/v1/invoices` son lo mismo.
|
|
@@ -565,4 +619,35 @@ export declare class PimiaClient {
|
|
|
565
619
|
private captureRateLimit;
|
|
566
620
|
private retryDelay;
|
|
567
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* Arma el `FormData` de una operación multipart con las conversiones que el
|
|
624
|
+
* servidor de Pimia espera, que **no** son las que hace `FormData` sola.
|
|
625
|
+
*
|
|
626
|
+
* Tres reglas, y las tres salen del contrato, no de la costumbre:
|
|
627
|
+
*
|
|
628
|
+
* - **Los booleanos viajan como `1` y `0`.** Lo dice el propio spec en
|
|
629
|
+
* `ExpenseRequest.is_attachment_receipt_removed`: «en `multipart/form-data`
|
|
630
|
+
* viaja como `1` o `0`». Un `String(false)` daría `"false"`, que PHP lee
|
|
631
|
+
* como verdadero.
|
|
632
|
+
* - **Los objetos y arrays viajan como JSON en una cadena.** También del
|
|
633
|
+
* spec, en `ExpenseRequest.customFields`: «viaja como cadena JSON:
|
|
634
|
+
* `[{"id":3,"value":"REF-42"}]`».
|
|
635
|
+
* - **`null` y `undefined` se omiten**, en vez de mandar `"null"`. Un campo
|
|
636
|
+
* ausente es un campo ausente; la cadena `"null"` es un valor.
|
|
637
|
+
*
|
|
638
|
+
* Un `Blob` o un `File` se añaden tal cual. Con un `File` el runtime manda ya
|
|
639
|
+
* su nombre; con un `Blob` suelto se puede dar uno pasando `[blob, 'x.pdf']`,
|
|
640
|
+
* que es la forma que el tercer argumento de `append` admite.
|
|
641
|
+
*
|
|
642
|
+
* ```ts
|
|
643
|
+
* await client.post('/expenses', toFormData({
|
|
644
|
+
* expense_date: '2026-08-24',
|
|
645
|
+
* expense_category_id: 3,
|
|
646
|
+
* amount: 12100,
|
|
647
|
+
* attachment_receipt: ficheroPdf,
|
|
648
|
+
* customFields: [{ id: 3, value: 'REF-42' }],
|
|
649
|
+
* }))
|
|
650
|
+
* ```
|
|
651
|
+
*/
|
|
652
|
+
export declare function toFormData(fields: Record<string, unknown | [Blob, string]>): FormData;
|
|
568
653
|
export {};
|
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';
|
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';
|
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
|
},
|