• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homer0 / packages / 4422859008

pending completion
4422859008

push

github

homer0
fix(eslint-plugin): use project as an array in the preset

720 of 720 branches covered (100.0%)

Branch coverage included in aggregate %.

2027 of 2027 relevant lines covered (100.0%)

45.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/packages/public/api-utils/src/apiClient.ts
1
import { EndpointsGenerator, type EndpointsGeneratorOptions } from './endpointsGenerator';
1✔
2
import type {
3
  EndpointDefinition,
4
  EndpointsDict,
5
  ErrorResponse,
6
  FetchClient,
7
  FetchOptions,
8
} from './types';
9
/**
10
 * The options for the client constructor.
11
 */
12
export type APIClientOptions = {
13
  /**
14
   * The base URL for the endpoints.
15
   */
16
  url: string;
17
  /**
18
   * The dictionary with the endpoints' definitions.
19
   */
20
  endpoints: EndpointsDict;
21
  /**
22
   * The fetch client that will be used to make the requests.
23
   */
24
  fetchClient: FetchClient;
25
  /**
26
   * A dictionary with default headers to include on every request.
27
   */
28
  defaultHeaders?: Record<string, unknown>;
29
  /**
30
   * Custom options for the service in charge of the endpoints.
31
   */
32
  endpointsGenerator?: {
33
    /**
34
     * The class to use for the endpoints generator. It has to to be a subclass of
35
     * {@link EndpointsGenerator}.
36
     */
37
    Class?: typeof EndpointsGenerator;
38
    /**
39
     * Custom options for the endpoints generator.
40
     *
41
     * @see {@link EndpointsGeneratorOptions} .
42
     */
43
    options?: Omit<EndpointsGeneratorOptions, 'url' | 'endpoints'>;
44
  };
45
};
46
/**
47
 * A custom overwrite for the fetch body, since the client supports objects, and later
48
 * stringifies them.
49
 */
50
export type APIClientBodyInit = string | Record<string | number, unknown> | BodyInit;
51
/**
52
 * A custom overwrite for the fetch options, to support the custom body type, and the
53
 * option for formatting.
54
 */
55
export type APIClientFetchOptions = Omit<FetchOptions, 'body'> & {
56
  /**
57
   * The body of the request.
58
   */
59
  body?: APIClientBodyInit;
60
  /**
61
   * Whether or not the response should _"JSON decoded"_.
62
   */
63
  json?: boolean;
64
};
65
/**
66
 * Responses with an status equal or greater than this one will be considered failed, and
67
 * their promises will be rejected.
68
 *
69
 * The reason for the variable is to avoid a magic number in the code, and to install a
70
 * lib just to get a single status code.
71
 */
72
const BAD_REQUEST_STATUS = 400;
1✔
73
/**
74
 * A very simple client to work with an API.
75
 */
76
export class APIClient {
1✔
77
  /**
78
   * The service in charge of generating the URLs for the endpoints.
79
   */
80
  protected endpoints: EndpointsGenerator;
18✔
81
  /**
82
   * A dictionary with default headers to include on every request.
83
   */
84
  protected defaultHeaders: Record<string, unknown>;
18✔
85
  /**
86
   * A "bearer" authentication token to include on every request.
87
   */
88
  protected authorizationToken: string = '';
18✔
89
  /**
90
   * The fetch client that will be used to make the requests.
91
   */
92
  protected fetchClient: FetchClient;
18✔
93
  constructor({
94
    url,
95
    endpoints,
96
    fetchClient,
97
    defaultHeaders = {},
18✔
98
    endpointsGenerator = {},
18✔
99
  }: APIClientOptions) {
100
    const { Class = EndpointsGenerator, options = {} } = endpointsGenerator;
18✔
101
    this.endpoints = new Class({ ...options, url, endpoints });
18✔
102
    this.fetchClient = fetchClient;
18✔
103
    this.defaultHeaders = defaultHeaders;
18✔
104
  }
105
  /**
106
   * Generates an endpoint's URL.
107
   *
108
   * @param key         The key property of the endpoint in the flatten dictionary.
109
   * @param parameters  A dictionary of paramteres that will replace the placeholders
110
   *                    in the path. If a parameter doesn't have a placeholder, it will
111
   *                    be added to the query string.
112
   * @returns A generated endpoint URL.
113
   * @throws If the endpoint wasn't specified in the dictionary.
114
   */
115
  endpoint(key: string, parameters: Record<string, unknown> = {}): string {
1✔
116
    return this.endpoints.get(key, parameters);
1✔
117
  }
118
  /**
119
   * Makes a `GET` request.
120
   *
121
   * @param url      The request URL.
122
   * @param options  The request options.
123
   * @template ResponseType  The data type for the response.
124
   */
125
  get<ResponseType = unknown>(
126
    url: string,
127
    options: APIClientFetchOptions = {},
4✔
128
  ): Promise<ResponseType> {
129
    return this.fetch(url, options);
5✔
130
  }
131
  /**
132
   * Makes a `HEAD` request.
133
   *
134
   * @param url      The request URL.
135
   * @param options  The request options.
136
   * @template ResponseType  The data type for the response.
137
   */
138
  head<ResponseType = unknown>(
139
    url: string,
140
    options: APIClientFetchOptions = {},
1✔
141
  ): Promise<ResponseType> {
142
    return this.fetch(url, { ...options, method: 'head' });
1✔
143
  }
144
  /**
145
   * Makes a `POST` request.
146
   *
147
   * @param url      The request URL.
148
   * @param body     The request payload.
149
   * @param options  The request options.
150
   * @template ResponseType  The data type for the response.
151
   */
152
  post<ResponseType = unknown>(
153
    url: string,
154
    body: APIClientBodyInit,
155
    options: APIClientFetchOptions = {},
2✔
156
  ): Promise<ResponseType> {
157
    return this.fetch(url, { method: 'post', ...options, body });
8✔
158
  }
159
  /**
160
   * Makes a `PATCH` request.
161
   *
162
   * @param url      The request URL.
163
   * @param body     The request payload.
164
   * @param options  The request options.
165
   * @template ResponseType  The data type for the response.
166
   */
167
  patch<ResponseType = unknown>(
168
    url: string,
169
    body: APIClientBodyInit,
170
    options: APIClientFetchOptions = {},
1✔
171
  ): Promise<ResponseType> {
172
    return this.post(url, body, { ...options, method: 'patch' });
1✔
173
  }
174
  /**
175
   * Makes a `PUT` request.
176
   *
177
   * @param url      The request URL.
178
   * @param body     The request payload.
179
   * @param options  The request options.
180
   * @template ResponseType  The data type for the response.
181
   */
182
  put<ResponseType = unknown>(
183
    url: string,
184
    body: APIClientBodyInit,
185
    options: APIClientFetchOptions = {},
1✔
186
  ): Promise<ResponseType> {
187
    return this.post(url, body, { ...options, method: 'put' });
1✔
188
  }
189
  /**
190
   * Makes a `DELETE` request.
191
   *
192
   * @param url      The request URL.
193
   * @param body     The request payload.
194
   * @param options  The request options.
195
   * @template ResponseType  The data type for the response.
196
   */
197
  delete<ResponseType = unknown>(
198
    url: string,
199
    body: APIClientBodyInit = {},
1✔
200
    options: APIClientFetchOptions = {},
1✔
201
  ): Promise<ResponseType> {
202
    return this.post(url, body, { ...options, method: 'delete' });
1✔
203
  }
204
  /**
205
   * Formats an error response into a proper Error object. This method should proabably be
206
   * overwritten to accomodate the error messages for the API it's being used for.
207
   *
208
   * @param response  A received response from a request.
209
   * @param status    The HTTP status of the response.
210
   * @template ResponseType  The type of the error response.
211
   */
212
  protected formatError<ResponseType extends ErrorResponse>(
213
    response: ResponseType,
214
    status: number,
215
  ) {
216
    const error = response.error || 'Unknown error';
2✔
217
    const message = `[${status}]: ${error}`;
2✔
218
    return new Error(message);
2✔
219
  }
220
  /**
221
   * Generates a dictionary of headers using the service's
222
   * {@link APIClient.defaultHeaders} property as base.
223
   * If a token was set using {@link APIClient.setAuthorizationToken}, the method will add
224
   * an `Authorization`
225
   * header for the bearer token.
226
   *
227
   * @param overwrites  Extra headers to add.
228
   */
229
  protected getHeaders(overwrites: Record<string, unknown> = {}): Record<string, string> {
11✔
230
    const headers = { ...this.defaultHeaders };
14✔
231
    if (this.authorizationToken) {
14✔
232
      // eslint-disable-next-line dot-notation
233
      headers['Authorization'] = `Bearer ${this.authorizationToken}`;
1✔
234
    }
235

236
    return Object.entries({ ...headers, ...overwrites }).reduce<Record<string, string>>(
14✔
237
      (acc, [key, value]) => {
238
        acc[key] = String(value);
4✔
239
        return acc;
4✔
240
      },
241
      {},
242
    );
243
  }
244
  /**
245
   * Makes a request.
246
   *
247
   * @param url      The request URL.
248
   * @param options  The request options.
249
   * @template ResponseType  The data type for the response.
250
   */
251
  protected async fetch<ResponseType = unknown>(
252
    url: string,
253
    options: APIClientFetchOptions,
254
  ): Promise<ResponseType> {
255
    // Get a new reference of the request options.
256
    const opts = { ...options };
14✔
257
    // Format the request method and check if it should use the default.
258
    opts.method = opts.method ? opts.method.toUpperCase() : 'GET';
14✔
259
    // Get the request headers.
260
    const headers = this.getHeaders(opts.headers as Record<string, string>);
14✔
261
    // Format the flag the method will use to decided whether to decode the response or not.
262
    const handleAsJSON = typeof opts.json === 'boolean' ? opts.json : true;
14✔
263
    // If the options include a body...
264
    let { body } = opts;
14✔
265
    if (body) {
14✔
266
      // Let's first check if there are headers and if a `Content-Type` has been set.
267
      const hasContentType = Object.keys(headers).some(
8✔
268
        (name) => name.toLowerCase() === 'content-type',
4✔
269
      );
270
      // If the body is an object...
271
      if (typeof opts.body === 'object') {
8✔
272
        // ...and if it's an object literal...
273
        if (Object.getPrototypeOf(opts.body).constructor.name === 'Object') {
7✔
274
          // ...encode it.
275
          body = JSON.stringify(opts.body);
6✔
276
        }
277
        // If no `Content-Type` was defined, let's assume is a JSON request.
278
        if (!hasContentType) {
7✔
279
          headers['Content-Type'] = 'application/json';
5✔
280
        }
281
      }
282
    }
283

284
    // Remove the necessary options in order to make it a valid `FetchOptions` object.
285
    delete opts.json;
14✔
286
    delete opts.body;
14✔
287
    const fetchOpts = opts as FetchOptions;
14✔
288
    // This check is to avoid pushing an empty object on the request options.
289
    if (Object.keys(headers).length) {
14✔
290
      fetchOpts.headers = headers;
7✔
291
    }
292

293
    fetchOpts.body = body as BodyInit;
14✔
294

295
    const response = await this.fetchClient(url, fetchOpts);
14✔
296
    const { status } = response;
14✔
297
    let nextStep: unknown;
298
    // If the response should be handled as JSON and it has a `json()` method...
299
    if (handleAsJSON && typeof response.json === 'function') {
14✔
300
      /**
301
       * Since some clients fail to decode an empty response, we'll try to decode it,
302
       * but if it fails, it will return an empty object.
303
       */
304
      nextStep = await response.json().catch(() => ({}));
13✔
305
    } else {
306
      // If the response shouldn't be handled as JSON, let's keep the raw object.
307
      nextStep = response;
1✔
308
    }
309

310
    if (status >= BAD_REQUEST_STATUS) {
14✔
311
      throw this.formatError(nextStep as ErrorResponse, status);
2✔
312
    }
313

314
    return nextStep as ResponseType;
12✔
315
  }
316
  /**
317
   * Sets a bearer token for all the requests.
318
   *
319
   * @param token  The new authorization token. If the value is empty, it will remove
320
   *               any token previously saved.
321
   */
322
  setAuthorizationToken(token: string = '') {
1✔
323
    this.authorizationToken = token;
3✔
324
  }
325
  /**
326
   * Gets the current authorization token used by the service.
327
   */
328
  getAuthorizationToken(): string {
329
    return this.authorizationToken;
2✔
330
  }
331
  /**
332
   * Sets the default headers for all the requests.
333
   *
334
   * @param headers    The new default headers.
335
   * @param overwrite  If `false`, it will merge the new default headers with the
336
   *                   current ones.
337
   */
338
  setDefaultHeaders(headers: Record<string, string> = {}, overwrite: boolean = true) {
4✔
339
    this.defaultHeaders = {
4✔
340
      ...(overwrite ? {} : this.defaultHeaders),
4✔
341
      ...headers,
342
    };
343
  }
344
  /**
345
   * Gets the current default headers used by the service.
346
   */
347
  getDefaultHeaders(): Record<string, unknown> {
348
    return {
4✔
349
      ...this.defaultHeaders,
350
    };
351
  }
352
  /**
353
   * Gets the dictionary of endpoints the service uses.
354
   */
355
  getEndpoints(): Record<string, EndpointDefinition> {
356
    return this.endpoints.getEndpoints();
1✔
357
  }
358
  /**
359
   * Gets the fetch client the service uses for making the requests.
360
   */
361
  getFetchClient(): FetchClient {
362
    return this.fetchClient;
1✔
363
  }
364
  /**
365
   * Gets the base URL the service uses for the endpoints.
366
   */
367
  getUrl() {
368
    return this.endpoints.getUrl();
1✔
369
  }
370
}
371
/**
372
 * Shorthand for `new APIClient()`.
373
 *
374
 * @param args  The same parameters as the {@link APIClient} constructor.
375
 * @returns A new instance of {@link APIClient}.
376
 */
377
export const apiClient = (...args: ConstructorParameters<typeof APIClient>): APIClient =>
1✔
378
  new APIClient(...args);
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc