Skip to content

Commit 68bf8b9

Browse files
authored
feat(api-client): Add api-client (#1043)
1 parent 0aaf2fa commit 68bf8b9

File tree

11 files changed

+1134
-235
lines changed

11 files changed

+1134
-235
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ My Node.js packages
44

55
## Packages
66

7+
- [`api-client`](./packages/api-client) [![npm version](https://img.shields.io/npm/v/@ffflorian/api-client.svg)](https://npmjs.com/packages/@ffflorian/api-client)
78
- [`auto-merge`](./packages/auto-merge) [![npm version](https://img.shields.io/npm/v/@ffflorian/auto-merge.svg)](https://npmjs.com/packages/@ffflorian/auto-merge)
89
- [`crates-updater`](./packages/crates-updater) [![npm version](https://img.shields.io/npm/v/crates-updater.svg)](https://npmjs.com/packages/crates-updater)
910
- [`double-linked-list`](./packages/double-linked-list)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"eslint-plugin-sort-keys-fix": "1.1.2",
2525
"eslint-plugin-typescript-sort-keys": "3.3.0",
2626
"eslint-plugin-unused-imports": "4.3.0",
27-
"lerna": "9.0.0",
27+
"lerna": "9.0.1",
2828
"oxlint": "1.25.0",
2929
"prettier": "3.6.2",
3030
"rimraf": "6.1.0",

packages/api-client/LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

packages/api-client/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# api-client [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![npm version](https://img.shields.io/npm/v/@ffflorian/api-client.svg?style=flat)](https://www.npmjs.com/package/@ffflorian/api-client)
2+
3+
Simple API client using fetch ([Node.js](https://nodejs.org/api/globals.html#fetch) / [Web](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API))
4+
5+
## Prerequisites
6+
7+
- [Node.js](https://nodejs.org) >= 14
8+
- npm (preinstalled) or [yarn](https://classic.yarnpkg.com)
9+
10+
## Installation
11+
12+
ℹ️ This is a pure [ESM](https://nodejs.org/api/esm.html#introduction) module.
13+
14+
Run `yarn global add @ffflorian/api-client` or `npm i -g @ffflorian/api-client`.
15+
16+
## Usage
17+
18+
```ts
19+
import {APIClient} from '@ffflorian/api-client';
20+
21+
const apiClient = new APIClient();
22+
try {
23+
const data = await apiClient.get('https://example.com');
24+
} catch (error) {
25+
console.error(error);
26+
}
27+
```

packages/api-client/package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"author": "Florian Imdahl <[email protected]>",
3+
"description": "Simple API client using fetch",
4+
"devDependencies": {
5+
"rimraf": "6.1.0",
6+
"typescript": "5.9.3"
7+
},
8+
"engines": {
9+
"node": ">= 18.0"
10+
},
11+
"exports": "./dist/index.js",
12+
"files": [
13+
"dist"
14+
],
15+
"keywords": [
16+
"cli",
17+
"typescript"
18+
],
19+
"license": "GPL-3.0",
20+
"module": "dist/index.js",
21+
"name": "@ffflorian/api-client",
22+
"repository": "https://github.com/ffflorian/node-packages/tree/main/packages/api-client",
23+
"scripts": {
24+
"build": "tsc -p tsconfig.build.json",
25+
"clean": "rimraf dist",
26+
"dist": "yarn clean && yarn build",
27+
"test": "exit 0"
28+
},
29+
"type": "module",
30+
"version": "2.0.0"
31+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type {
2+
APIResponse,
3+
BasicRequestOptions,
4+
ApiClientConfig,
5+
RequestInterceptor,
6+
ResponseInterceptor,
7+
RequestInitWithMethod,
8+
RequestOptions,
9+
} from './types.js';
10+
11+
export class APIClient {
12+
public interceptors: {request: RequestInterceptor[]; response: ResponseInterceptor[]} = {
13+
request: [],
14+
response: [],
15+
};
16+
17+
constructor(
18+
private baseUrl: string,
19+
private config?: ApiClientConfig
20+
) {}
21+
22+
setBaseURL(baseUrl: string): void {
23+
this.baseUrl = baseUrl;
24+
}
25+
26+
setConfig(config: Partial<RequestInit>): void {
27+
this.config = config;
28+
}
29+
30+
private formatData(response: Response, options?: BasicRequestOptions): Promise<any> {
31+
const responseType = options?.responseType || 'json';
32+
switch (responseType) {
33+
case 'arraybuffer':
34+
return response.arrayBuffer();
35+
case 'blob':
36+
return response.blob();
37+
case 'text':
38+
return response.text();
39+
case 'json':
40+
default:
41+
return response.json();
42+
}
43+
}
44+
45+
async request(endpoint: string, options: RequestOptions): Promise<Response> {
46+
const url = new URL(endpoint, this.baseUrl);
47+
48+
if (options.params) {
49+
for (const [key, param] of Object.entries(options.params)) {
50+
if (param !== null && param !== undefined) {
51+
url.searchParams.append(key, String(param));
52+
}
53+
}
54+
}
55+
56+
let requestOptions: RequestInitWithMethod = {
57+
method: options.method.toUpperCase(),
58+
...this.config,
59+
};
60+
61+
if (options.headers) {
62+
requestOptions.headers = {
63+
...requestOptions.headers,
64+
...options.headers,
65+
};
66+
}
67+
68+
if (this.config?.auth) {
69+
const {username, password} = this.config.auth;
70+
const encoded = btoa(`${username}:${password}`);
71+
72+
requestOptions.headers = {
73+
...requestOptions.headers,
74+
Authorization: `Basic ${encoded}`,
75+
};
76+
}
77+
78+
if (options.data) {
79+
if (options.data instanceof Object) {
80+
requestOptions.headers = {
81+
...requestOptions.headers,
82+
'Content-Type': 'application/json',
83+
};
84+
options.data = JSON.stringify(options.data);
85+
}
86+
requestOptions.body = options.data;
87+
}
88+
89+
if (this.interceptors.request.length > 0) {
90+
for (const interceptor of this.interceptors.request) {
91+
requestOptions = await interceptor(url, requestOptions);
92+
}
93+
}
94+
95+
const response = await fetch(url, requestOptions);
96+
if (!response.ok) {
97+
throw new Error(`${options.method.toUpperCase()} request failed: ${response.statusText}`);
98+
}
99+
100+
if (this.interceptors.response.length > 0) {
101+
for (const interceptor of this.interceptors.response) {
102+
await interceptor(response);
103+
}
104+
}
105+
106+
return response;
107+
}
108+
109+
async delete<T = any>(endpoint: string, options?: BasicRequestOptions): Promise<APIResponse<T>> {
110+
const request = await this.request(endpoint, {...options, method: 'DELETE'});
111+
const requestData = await this.formatData(request, options);
112+
return {data: requestData, headers: request.headers, status: request.status};
113+
}
114+
115+
async get<T = any>(endpoint: string, options?: BasicRequestOptions): Promise<APIResponse<T>> {
116+
const request = await this.request(endpoint, {...options, method: 'GET'});
117+
const requestData = await this.formatData(request, options);
118+
return {data: requestData, headers: request.headers, status: request.status};
119+
}
120+
121+
async head<T = any>(endpoint: string, options?: BasicRequestOptions): Promise<APIResponse<T>> {
122+
const request = await this.request(endpoint, {...options, method: 'HEAD'});
123+
const requestData = await this.formatData(request, options);
124+
return {data: requestData, headers: request.headers, status: request.status};
125+
}
126+
127+
async patch<T = any>(endpoint: string, data?: any, options?: BasicRequestOptions): Promise<APIResponse<T>> {
128+
const request = await this.request(endpoint, {...options, data, method: 'PATCH'});
129+
const requestData = await this.formatData(request, options);
130+
return {data: requestData, headers: request.headers, status: request.status};
131+
}
132+
133+
async options<T = any>(endpoint: string, options?: BasicRequestOptions): Promise<APIResponse<T>> {
134+
const request = await this.request(endpoint, {...options, method: 'OPTIONS'});
135+
const requestData = await this.formatData(request, options);
136+
return {data: requestData, headers: request.headers, status: request.status};
137+
}
138+
139+
async post<T = any>(endpoint: string, data?: any, options?: BasicRequestOptions): Promise<APIResponse<T>> {
140+
const request = await this.request(endpoint, {data, ...options, method: 'POST'});
141+
const requestData = await this.formatData(request, options);
142+
return {data: requestData, headers: request.headers, status: request.status};
143+
}
144+
145+
async put<T = any>(endpoint: string, data?: any, options?: BasicRequestOptions): Promise<APIResponse<T>> {
146+
const request = await this.request(endpoint, {data, ...options, method: 'PUT'});
147+
const requestData = await this.formatData(request, options);
148+
return {data: requestData, headers: request.headers, status: request.status};
149+
}
150+
}

packages/api-client/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './APIClient.js';
2+
export * from './types.js';

packages/api-client/src/types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface RequestOptions {
2+
data?: any;
3+
headers?: HeadersInit;
4+
method:
5+
| 'GET'
6+
| 'POST'
7+
| 'PUT'
8+
| 'DELETE'
9+
| 'PATCH'
10+
| 'HEAD'
11+
| 'OPTIONS'
12+
| 'get'
13+
| 'post'
14+
| 'put'
15+
| 'delete'
16+
| 'patch'
17+
| 'head'
18+
| 'options';
19+
params?: object;
20+
responseType?: 'arraybuffer' | 'json' | 'text' | 'blob';
21+
}
22+
23+
export type ApiClientConfig = Partial<Omit<RequestInit, 'method'>> & {auth?: {password: string; username: string}};
24+
25+
export type BasicRequestOptions = Omit<RequestOptions, 'method'>;
26+
27+
export type RequestInitWithMethod = Required<Pick<RequestInit, 'method'>> & Omit<RequestInit, 'method'>;
28+
29+
export type RequestInterceptor = (
30+
url: URL,
31+
options: RequestInitWithMethod
32+
) => RequestInitWithMethod | Promise<RequestInitWithMethod>;
33+
34+
export type ResponseInterceptor = (response: Response) => void | Promise<void>;
35+
36+
export interface APIResponse<T> {
37+
data: T;
38+
headers: Headers;
39+
status: number;
40+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"exclude": ["dist", "node_modules", "**/*.test.ts"],
3+
"extends": "./tsconfig.json"
4+
}

packages/api-client/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"esModuleInterop": true,
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
6+
"outDir": "dist",
7+
"rootDir": "src",
8+
"target": "ES2018"
9+
},
10+
"exclude": ["dist", "node_modules"],
11+
"extends": "../../tsconfig.json"
12+
}

0 commit comments

Comments
 (0)