@pimia/sdk 0.1.0 → 0.2.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/dist/client.d.ts CHANGED
@@ -34,12 +34,50 @@ export interface RequestOptions {
34
34
  body?: unknown;
35
35
  headers?: Record<string, string>;
36
36
  signal?: AbortSignal;
37
+ /**
38
+ * Clave de idempotencia para este `POST`. Manda una única por operación —un
39
+ * UUID nuevo— y reúsala SOLO en los reintentos de esa misma operación:
40
+ * Pimia ejecuta la escritura una vez y reproduce la respuesta original en
41
+ * los reintentos. La misma clave con otro cuerpo responde 422.
42
+ *
43
+ * Para saber si lo que recibiste es un eco y no una escritura nueva, usa
44
+ * {@link PimiaClient.requestWithMeta} y mira `meta.idempotentReplay`.
45
+ */
46
+ idempotencyKey?: string;
37
47
  }
38
48
  /** Cabeceras de rate limit que devuelve la API en cada respuesta. */
39
49
  export interface RateLimit {
40
50
  limit?: number;
41
51
  remaining?: number;
42
52
  }
53
+ /**
54
+ * Lo que la respuesta dice ADEMÁS del cuerpo.
55
+ *
56
+ * Va por petición y no como estado del cliente —al contrario que
57
+ * {@link PimiaClient.rateLimit}— a propósito: `idempotentReplay` solo
58
+ * significa algo referido a UNA llamada concreta, y justo se consulta cuando
59
+ * hay reintentos, que es cuando puede haber varias en vuelo. Un campo
60
+ * compartido en el cliente daría la respuesta de otra.
61
+ */
62
+ export interface ResponseMeta {
63
+ status: number;
64
+ /**
65
+ * `true` si Pimia reprodujo la respuesta de una petición anterior con la
66
+ * misma `Idempotency-Key` en vez de volver a escribir. Es la diferencia
67
+ * entre «he creado la factura» y «ya estaba creada»: sin esto, un partner
68
+ * no puede distinguirlas en sus propios registros.
69
+ */
70
+ idempotentReplay: boolean;
71
+ requestId?: string;
72
+ rateLimit: RateLimit;
73
+ }
74
+ /** Cuerpo y metadatos de una misma respuesta. */
75
+ export interface ResponseWithMeta<T> {
76
+ data: T;
77
+ meta: ResponseMeta;
78
+ }
79
+ /** Lo que se puede afinar en una escritura (`post`/`put`/`patch`). */
80
+ export type WriteOptions = Pick<RequestOptions, 'headers' | 'query' | 'signal' | 'idempotencyKey'>;
43
81
  export declare class PimiaClient {
44
82
  readonly oauth: OAuth;
45
83
  private readonly baseUrl;
@@ -58,30 +96,47 @@ export declare class PimiaClient {
58
96
  get invoices(): {
59
97
  list: (query?: RequestOptions["query"]) => Promise<unknown>;
60
98
  get: (id: number | string) => Promise<unknown>;
61
- create: (body: unknown) => Promise<unknown>;
62
- update: (id: number | string, body: unknown) => Promise<unknown>;
99
+ create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
100
+ update: (id: number | string, body: unknown, options?: WriteOptions) => Promise<unknown>;
63
101
  };
64
102
  get customers(): {
65
103
  list: (query?: RequestOptions["query"]) => Promise<unknown>;
66
104
  get: (id: number | string) => Promise<unknown>;
67
- create: (body: unknown) => Promise<unknown>;
68
- update: (id: number | string, body: unknown) => Promise<unknown>;
105
+ create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
106
+ update: (id: number | string, body: unknown, options?: WriteOptions) => Promise<unknown>;
69
107
  };
70
108
  get estimates(): {
71
109
  list: (query?: RequestOptions["query"]) => Promise<unknown>;
72
110
  get: (id: number | string) => Promise<unknown>;
73
- create: (body: unknown) => Promise<unknown>;
111
+ create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
74
112
  };
75
113
  get<T = unknown>(path: string, query?: RequestOptions['query']): Promise<T>;
76
- post<T = unknown>(path: string, body?: unknown): Promise<T>;
77
- put<T = unknown>(path: string, body?: unknown): Promise<T>;
78
- patch<T = unknown>(path: string, body?: unknown): Promise<T>;
114
+ post<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
115
+ put<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
116
+ patch<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
79
117
  delete<T = unknown>(path: string): Promise<T>;
80
118
  /**
81
119
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
82
120
  * `/invoices` y `/api/v1/invoices` son lo mismo.
83
121
  */
84
122
  request<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
123
+ /**
124
+ * Lo mismo que {@link request}, pero devuelve también los metadatos de la
125
+ * respuesta.
126
+ *
127
+ * Existe por la idempotencia: tras un reintento, el cuerpo es idéntico al de
128
+ * la primera llamada —ese es justo el contrato—, así que el cuerpo solo no
129
+ * dice si Pimia escribió o se limitó a repetirse. `meta.idempotentReplay` sí.
130
+ *
131
+ * ```ts
132
+ * const clave = crypto.randomUUID()
133
+ * const { data, meta } = await client.requestWithMeta('/estimates', {
134
+ * method: 'POST', body, idempotencyKey: clave,
135
+ * })
136
+ * if (meta.idempotentReplay) log('el presupuesto ya existía; no se duplicó')
137
+ * ```
138
+ */
139
+ requestWithMeta<T = unknown>(path: string, options?: RequestOptions): Promise<ResponseWithMeta<T>>;
85
140
  private currentTokens;
86
141
  /**
87
142
  * Refresca UNA sola vez aunque lo pidan N peticiones en paralelo, y persiste
package/dist/client.js CHANGED
@@ -46,36 +46,36 @@ export class PimiaClient {
46
46
  return {
47
47
  list: (query) => this.get('/invoices', query),
48
48
  get: (id) => this.get(`/invoices/${id}`),
49
- create: (body) => this.post('/invoices', body),
50
- update: (id, body) => this.put(`/invoices/${id}`, body),
49
+ create: (body, options) => this.post('/invoices', body, options),
50
+ update: (id, body, options) => this.put(`/invoices/${id}`, body, options),
51
51
  };
52
52
  }
53
53
  get customers() {
54
54
  return {
55
55
  list: (query) => this.get('/customers', query),
56
56
  get: (id) => this.get(`/customers/${id}`),
57
- create: (body) => this.post('/customers', body),
58
- update: (id, body) => this.put(`/customers/${id}`, body),
57
+ create: (body, options) => this.post('/customers', body, options),
58
+ update: (id, body, options) => this.put(`/customers/${id}`, body, options),
59
59
  };
60
60
  }
61
61
  get estimates() {
62
62
  return {
63
63
  list: (query) => this.get('/estimates', query),
64
64
  get: (id) => this.get(`/estimates/${id}`),
65
- create: (body) => this.post('/estimates', body),
65
+ create: (body, options) => this.post('/estimates', body, options),
66
66
  };
67
67
  }
68
68
  get(path, query) {
69
69
  return this.request(path, { method: 'GET', query });
70
70
  }
71
- post(path, body) {
72
- return this.request(path, { method: 'POST', body });
71
+ post(path, body, options) {
72
+ return this.request(path, { ...options, method: 'POST', body });
73
73
  }
74
- put(path, body) {
75
- return this.request(path, { method: 'PUT', body });
74
+ put(path, body, options) {
75
+ return this.request(path, { ...options, method: 'PUT', body });
76
76
  }
77
- patch(path, body) {
78
- return this.request(path, { method: 'PATCH', body });
77
+ patch(path, body, options) {
78
+ return this.request(path, { ...options, method: 'PATCH', body });
79
79
  }
80
80
  delete(path) {
81
81
  return this.request(path, { method: 'DELETE' });
@@ -85,6 +85,26 @@ export class PimiaClient {
85
85
  * `/invoices` y `/api/v1/invoices` son lo mismo.
86
86
  */
87
87
  async request(path, options = {}) {
88
+ const { data } = await this.requestWithMeta(path, options);
89
+ return data;
90
+ }
91
+ /**
92
+ * Lo mismo que {@link request}, pero devuelve también los metadatos de la
93
+ * respuesta.
94
+ *
95
+ * Existe por la idempotencia: tras un reintento, el cuerpo es idéntico al de
96
+ * la primera llamada —ese es justo el contrato—, así que el cuerpo solo no
97
+ * dice si Pimia escribió o se limitó a repetirse. `meta.idempotentReplay` sí.
98
+ *
99
+ * ```ts
100
+ * const clave = crypto.randomUUID()
101
+ * const { data, meta } = await client.requestWithMeta('/estimates', {
102
+ * method: 'POST', body, idempotencyKey: clave,
103
+ * })
104
+ * if (meta.idempotentReplay) log('el presupuesto ya existía; no se duplicó')
105
+ * ```
106
+ */
107
+ async requestWithMeta(path, options = {}) {
88
108
  let tokens = await this.currentTokens();
89
109
  if (isExpired(tokens, this.skew)) {
90
110
  tokens = await this.refreshTokens(tokens);
@@ -99,6 +119,12 @@ export class PimiaClient {
99
119
  ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
100
120
  ...this.extraHeaders,
101
121
  ...options.headers,
122
+ // Después de `options.headers` para que la opción con nombre mande
123
+ // sobre una cabecera puesta a mano: si alguien usa las dos, la
124
+ // explícita del API es la que quiso de verdad.
125
+ ...(options.idempotencyKey === undefined
126
+ ? {}
127
+ : { 'idempotency-key': options.idempotencyKey }),
102
128
  authorization: `Bearer ${tokens.accessToken}`,
103
129
  },
104
130
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
@@ -106,7 +132,17 @@ export class PimiaClient {
106
132
  });
107
133
  this.captureRateLimit(response);
108
134
  if (response.ok) {
109
- return (await parseBody(response));
135
+ return {
136
+ data: (await parseBody(response)),
137
+ meta: {
138
+ status: response.status,
139
+ // Presente solo cuando Pimia reproduce; su ausencia significa
140
+ // «esta escritura ocurrió de verdad».
141
+ idempotentReplay: response.headers.get('idempotency-replayed') === 'true',
142
+ requestId: response.headers.get('x-request-id') ?? undefined,
143
+ rateLimit: this.lastRateLimit,
144
+ },
145
+ };
110
146
  }
111
147
  const body = await parseBody(response);
112
148
  const requestId = response.headers.get('x-request-id') ?? undefined;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * salen los tipos de `./api`.
7
7
  */
8
8
  export { PimiaClient } from './client.js';
9
- export type { PimiaClientOptions, RateLimit, RequestOptions } from './client.js';
9
+ export type { PimiaClientOptions, RateLimit, RequestOptions, 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';
12
12
  export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
@@ -33,5 +33,12 @@ export declare const SCOPES: {
33
33
  readonly agendaRead: "agenda:read";
34
34
  readonly agendaWrite: "agenda:write";
35
35
  readonly reportsRead: "reports:read";
36
+ /**
37
+ * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
38
+ * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
39
+ * Authorization Server.
40
+ */
41
+ readonly approvalsWrite: "approvals:write";
42
+ readonly approvalsSubmit: "approvals:submit";
36
43
  };
37
44
  export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
package/dist/index.js CHANGED
@@ -30,4 +30,11 @@ export const SCOPES = {
30
30
  agendaRead: 'agenda:read',
31
31
  agendaWrite: 'agenda:write',
32
32
  reportsRead: 'reports:read',
33
+ /**
34
+ * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
35
+ * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
36
+ * Authorization Server.
37
+ */
38
+ approvalsWrite: 'approvals:write',
39
+ approvalsSubmit: 'approvals:submit',
33
40
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimia/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.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)",