e621-client 1.1.3 → 1.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/demo.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { E621ApiClient } from "./src/client/E621ApiClient";
2
+
3
+ async function demo() {
4
+ const client = new E621ApiClient({
5
+ isBrowser: false,
6
+ client: "EstoBox (by Tommus621 on e621)",
7
+ });
8
+ const favs = await client.getFavorites({ userId: "964986", page: "2" });
9
+ console.log(favs.length);
10
+ }
11
+
12
+ demo().finally();
@@ -1,10 +1,35 @@
1
1
  import { E621Post } from "../types/e621/E621Post";
2
2
  import { E621User } from "../types/e621/E621User";
3
3
  import { E621ApiHeaders } from "./E621ApiHeaders";
4
+ import { GetAllFavoritesOptions, GetFavoritesOptions, GetPostsOptions, GetUsersOptions, Params } from "./options";
5
+ /**
6
+ * The option object used to initialize the API client.
7
+ */
4
8
  export interface E621ApiOptions {
9
+ /**
10
+ *
11
+ */
5
12
  client: string;
13
+ /**
14
+ * Required to set the client in an appropriate way for the environment.
15
+ *
16
+ * Browsers must send it through query parameters, other environments can set the `User-Agent` header.
17
+ */
6
18
  isBrowser: boolean;
19
+ /**
20
+ * Username and API of a user if they are known at constructor call.
21
+ *
22
+ * Can be added or removed later with `setCredentials`.
23
+ */
7
24
  credentials?: Credentials;
25
+ /**
26
+ * Whether to show more detailed logs for fancier requests (e.g. `getAllFavorites`).
27
+ */
28
+ debugLogs?: boolean;
29
+ /**
30
+ * The time between two requests when a single method does multiple (e.g. `getAllFavorites`).
31
+ */
32
+ batchRateLimit?: number;
8
33
  }
9
34
  export type GetOptions = {
10
35
  route: Route;
@@ -17,35 +42,20 @@ export interface Credentials {
17
42
  }
18
43
  export type EntityId = string | number;
19
44
  export type Route = string | number | (string | number)[];
20
- export type Params = Record<string, string>;
21
- export type Pagination = {
22
- limit?: string;
23
- page?: string;
24
- };
25
- export type GetUsersOptions = {
26
- nameMatches?: string;
27
- } & Params & Pagination;
28
- export type GetPostsOptions = {
29
- tags?: string;
30
- } & Pagination;
31
- export type GetFavoritesOptions = {
32
- userId?: string;
33
- } & Pagination;
34
- export type GetAllFavoritesOptions = {
35
- userId?: string;
36
- };
37
45
  export declare class E621ApiClient {
38
46
  protected static readonly BASE_URL = "https://e621.net/";
39
47
  private readonly client;
40
48
  private readonly isBrowser;
49
+ private readonly batchRateLimit;
41
50
  private credentials;
51
+ debugLogs: boolean;
42
52
  constructor(options: E621ApiOptions);
43
53
  getUsers(options?: GetUsersOptions): Promise<E621User[]>;
44
54
  getUserById(id: EntityId): Promise<E621User | null>;
45
55
  getPosts(options?: GetPostsOptions): Promise<E621Post[]>;
46
56
  getPostById(id: EntityId): Promise<E621Post | null>;
47
57
  getFavorites(options?: GetFavoritesOptions): Promise<E621Post[]>;
48
- getAllFavorites(options?: GetAllFavoritesOptions, debugLogs?: boolean): Promise<E621Post[]>;
58
+ getAllFavorites(options?: GetAllFavoritesOptions): Promise<E621Post[]>;
49
59
  setCredentials(credentials: Credentials | null): E621ApiClient;
50
60
  checkCredentials(credentials?: Credentials): Promise<boolean>;
51
61
  get<E>({ route, params, headers, }: GetOptions): Promise<E | null>;
@@ -14,21 +14,22 @@ const E621Post_1 = require("../types/e621/E621Post");
14
14
  const E621User_1 = require("../types/e621/E621User");
15
15
  const utils_1 = require("../utils");
16
16
  const E621ApiHeaders_1 = require("./E621ApiHeaders");
17
+ const options_1 = require("./options");
17
18
  class E621ApiClient {
18
19
  constructor(options) {
19
20
  this.credentials = null;
21
+ this.debugLogs = false;
20
22
  this.client = options.client;
21
23
  this.isBrowser = options.isBrowser;
22
24
  this.credentials = options.credentials || null;
25
+ this.batchRateLimit = options.batchRateLimit || 0;
23
26
  }
24
27
  // Users
25
28
  getUsers() {
26
29
  return __awaiter(this, arguments, void 0, function* (options = {}) {
27
30
  const users = yield this.get({
28
31
  route: "users",
29
- params: (0, utils_1.translateOptions)(options, {
30
- nameMatches: "search[name_matches]",
31
- }),
32
+ params: (0, options_1.translateGetUserOptions)(options),
32
33
  });
33
34
  return (users === null || users === void 0 ? void 0 : users.map(E621User_1.parseUser)) || [];
34
35
  });
@@ -61,20 +62,20 @@ class E621ApiClient {
61
62
  var _a;
62
63
  const response = yield this.get({
63
64
  route: "favorites",
64
- params: (0, utils_1.translateOptions)(options, { userId: "user_id" }),
65
+ params: (0, options_1.translateGetFavoritesOptions)(options),
65
66
  });
66
67
  return ((_a = response === null || response === void 0 ? void 0 : response.posts) === null || _a === void 0 ? void 0 : _a.map(E621Post_1.parsePost)) || [];
67
68
  });
68
69
  }
69
70
  getAllFavorites() {
70
- return __awaiter(this, arguments, void 0, function* (options = {}, debugLogs = false) {
71
+ return __awaiter(this, arguments, void 0, function* (options = {}) {
71
72
  var _a, _b;
72
73
  // max page size on e621 api
73
74
  const favorites = [];
74
75
  const limit = 300;
75
76
  let page = 1;
76
77
  while (true) {
77
- if (debugLogs) {
78
+ if (this.debugLogs) {
78
79
  const user = (_a = options.userId) !== null && _a !== void 0 ? _a : (_b = this.credentials) === null || _b === void 0 ? void 0 : _b.username;
79
80
  console.info(`page ${page} for user ${user}`);
80
81
  }
@@ -82,6 +83,7 @@ class E621ApiClient {
82
83
  if (posts.length === 0)
83
84
  break;
84
85
  favorites.push(...posts);
86
+ yield (0, utils_1.delay)(this.batchRateLimit);
85
87
  }
86
88
  return favorites;
87
89
  });
@@ -96,14 +98,8 @@ class E621ApiClient {
96
98
  if (credentials) {
97
99
  this.setCredentials(credentials);
98
100
  }
99
- const headers = new E621ApiHeaders_1.E621ApiHeaders();
100
- if (!this.isBrowser) {
101
- headers.addUserAgent(this.client);
102
- }
103
- return yield fetch(this.buildUrl("favorites"), {
104
- headers: headers.addAuthorization(this.credentials).build(),
105
- })
106
- .then((res) => res.ok)
101
+ return yield this.get({ route: "favorites" })
102
+ .then(() => true)
107
103
  .catch(() => false);
108
104
  });
109
105
  }
@@ -112,16 +108,20 @@ class E621ApiClient {
112
108
  if (!this.isBrowser) {
113
109
  headers.addUserAgent(this.client);
114
110
  }
115
- const response = yield fetch(this.buildUrl(route, params), {
111
+ const url = this.buildUrl(route, params);
112
+ const response = yield fetch(url, {
116
113
  method: "GET",
117
114
  headers: headers.addAuthorization(this.credentials).build(),
118
115
  });
119
116
  if (response.ok) {
120
- return response.json().catch(utils_1.logErrorAndReturnNull);
117
+ return response.json();
121
118
  }
122
- else {
119
+ else if (response.status === 404) {
123
120
  return null;
124
121
  }
122
+ else {
123
+ throw new Error(`Request to ${route} failed with status code ${response.status}`);
124
+ }
125
125
  });
126
126
  }
127
127
  buildUrl(route, params = {}) {
@@ -0,0 +1,25 @@
1
+ export type Params = Record<string, string>;
2
+ export type Pagination = {
3
+ limit?: string;
4
+ page?: string;
5
+ };
6
+ export type GetUsersOptions = {
7
+ nameMatches?: string;
8
+ } & Params & Pagination;
9
+ export type GetUserApiOptions = {
10
+ "search[name_matches]"?: string;
11
+ } & Pagination;
12
+ export type GetPostsOptions = {
13
+ tags?: string;
14
+ } & Pagination;
15
+ export type GetFavoritesOptions = {
16
+ userId?: string;
17
+ } & Pagination;
18
+ export type GetFavoritesApiOptions = {
19
+ user_id?: string;
20
+ } & Pagination;
21
+ export type GetAllFavoritesOptions = {
22
+ userId?: string;
23
+ };
24
+ export declare function translateGetUserOptions(options: GetUsersOptions): GetUserApiOptions;
25
+ export declare function translateGetFavoritesOptions(options: GetFavoritesOptions): GetFavoritesApiOptions;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.translateGetFavoritesOptions = exports.translateGetUserOptions = void 0;
4
+ const utils_1 = require("../utils");
5
+ function translateGetUserOptions(options) {
6
+ return (0, utils_1.translateOptions)(options, {
7
+ nameMatches: "search[name_matches]",
8
+ });
9
+ }
10
+ exports.translateGetUserOptions = translateGetUserOptions;
11
+ function translateGetFavoritesOptions(options) {
12
+ return (0, utils_1.translateOptions)(options, {
13
+ userId: "user_id",
14
+ });
15
+ }
16
+ exports.translateGetFavoritesOptions = translateGetFavoritesOptions;
package/dist/utils.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export declare function ensureArray<T>(value: T | T[]): T[];
2
- export declare function translateOptions<Options extends Record<string, string>>(options: Options, table: Partial<Options>): Record<string, string>;
3
- export declare function logErrorAndReturnNull(error: unknown): null;
2
+ export declare function delay(ms: number): Promise<void>;
3
+ export declare function translateOptions(options: Record<string, string | undefined>, table: Record<string, string | undefined>): Record<string, string>;
package/dist/utils.js CHANGED
@@ -1,6 +1,15 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.logErrorAndReturnNull = exports.translateOptions = exports.ensureArray = void 0;
12
+ exports.translateOptions = exports.delay = exports.ensureArray = void 0;
4
13
  function ensureArray(value) {
5
14
  if (Array.isArray(value)) {
6
15
  return value;
@@ -8,17 +17,22 @@ function ensureArray(value) {
8
17
  return [value];
9
18
  }
10
19
  exports.ensureArray = ensureArray;
20
+ function delay(ms) {
21
+ return __awaiter(this, void 0, void 0, function* () {
22
+ if (ms <= 0)
23
+ return;
24
+ return new Promise((res) => setTimeout(res, ms));
25
+ });
26
+ }
27
+ exports.delay = delay;
11
28
  function translateOptions(options, table) {
12
29
  var _a;
13
30
  const params = {};
14
31
  for (const [key, value] of Object.entries(options)) {
15
- params[(_a = table[key]) !== null && _a !== void 0 ? _a : key] = value;
32
+ if (value !== undefined) {
33
+ params[(_a = table[key]) !== null && _a !== void 0 ? _a : key] = value;
34
+ }
16
35
  }
17
36
  return params;
18
37
  }
19
38
  exports.translateOptions = translateOptions;
20
- function logErrorAndReturnNull(error) {
21
- console.error(error);
22
- return null;
23
- }
24
- exports.logErrorAndReturnNull = logErrorAndReturnNull;
package/jest.config.js ADDED
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ preset: "ts-jest",
3
+ testEnvironment: "node",
4
+ testMatch: ["**/?(*.)+(spec|test).[tj]s?(x)"],
5
+ testPathIgnorePatterns: ["/dist/"],
6
+ };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "e621-client",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "scripts": {
7
+ "test": "jest",
7
8
  "build": "tsc",
8
9
  "prepublishOnly": "tsc"
9
10
  },
@@ -12,8 +13,11 @@
12
13
  "license": "ISC",
13
14
  "description": "A simple client for the e621 API",
14
15
  "devDependencies": {
16
+ "@types/jest": "^29.5.12",
15
17
  "@types/node": "^20.14.2",
18
+ "jest": "^29.7.0",
16
19
  "prettier": "^3.3.1",
20
+ "ts-jest": "^29.2.3",
17
21
  "ts-node": "^10.9.2",
18
22
  "typescript": "^5.4.5"
19
23
  }
@@ -0,0 +1,305 @@
1
+ import { E621ApiClient } from "./E621ApiClient";
2
+ import { MockedFetch, mockFetch } from "../test-utils/mockFetch";
3
+ import { createClient } from "../test-utils/createClient";
4
+ import { createApiUser, createUser } from "../test-utils/createUser";
5
+ import { createApiPost, createPost } from "../test-utils/createPost";
6
+ import {
7
+ GetFavoritesResponse,
8
+ GetPostResponse,
9
+ GetPostsResponse,
10
+ } from "../types/responses";
11
+ import { delay } from "../utils";
12
+
13
+ describe("E621ApiClient", () => {
14
+ beforeEach(() => {
15
+ jest.resetAllMocks();
16
+ });
17
+
18
+ describe("generic get endpoint", () => {
19
+ let fetchMock: MockedFetch;
20
+
21
+ beforeEach(() => {
22
+ fetchMock = mockFetch();
23
+ });
24
+
25
+ it("should build the url correctly", async () => {
26
+ const client = createClient();
27
+
28
+ await client.get({ route: "favorites" });
29
+ expect(fetchMock).toHaveBeenCalledWith(
30
+ "https://e621.net/favorites.json",
31
+ expect.anything(),
32
+ );
33
+
34
+ await client.get({ route: 1234 });
35
+ expect(fetchMock).toHaveBeenCalledWith(
36
+ "https://e621.net/1234.json",
37
+ expect.anything(),
38
+ );
39
+
40
+ await client.get({ route: ["users", "tommus"] });
41
+ expect(fetchMock).toHaveBeenCalledWith(
42
+ "https://e621.net/users/tommus.json",
43
+ expect.anything(),
44
+ );
45
+
46
+ await client.get({ route: ["users", 1234] });
47
+ expect(fetchMock).toHaveBeenCalledWith(
48
+ "https://e621.net/users/1234.json",
49
+ expect.anything(),
50
+ );
51
+ });
52
+
53
+ it("should pass the client in query params when in browser mode", async () => {
54
+ const client = createClient({
55
+ client: "jest",
56
+ isBrowser: true,
57
+ });
58
+
59
+ await client.get({ route: "test" });
60
+ expect(fetchMock).toHaveBeenCalledWith(
61
+ "https://e621.net/test.json?_client=jest",
62
+ expect.anything(),
63
+ );
64
+ });
65
+
66
+ it("should pass the client in headers when not in browser mode", async () => {
67
+ const client = createClient({
68
+ client: "jest",
69
+ isBrowser: false,
70
+ });
71
+
72
+ await client.get({ route: "test" });
73
+ expect(fetchMock).toHaveBeenCalledWith(
74
+ "https://e621.net/test.json",
75
+ expect.objectContaining({
76
+ headers: { "User-Agent": "jest" },
77
+ }),
78
+ );
79
+ });
80
+
81
+ it("should add the query parameters properly", async () => {
82
+ const client = createClient({ isBrowser: false });
83
+
84
+ await client.get({
85
+ route: "test",
86
+ params: {
87
+ param1: "abc",
88
+ param2: "def",
89
+ },
90
+ });
91
+ expect(fetchMock).toHaveBeenCalledWith(
92
+ "https://e621.net/test.json?param1=abc&param2=def",
93
+ expect.anything(),
94
+ );
95
+ });
96
+ });
97
+
98
+ describe("specific endpoints", () => {
99
+ let client: E621ApiClient;
100
+ let getMock: jest.SpyInstance;
101
+
102
+ beforeEach(() => {
103
+ client = createClient();
104
+ getMock = jest.spyOn(client, "get").mockResolvedValue(null);
105
+ });
106
+
107
+ describe("getUsers", () => {
108
+ it("should use the correct default values", async () => {
109
+ await client.getUsers();
110
+ expect(getMock).toHaveBeenCalledWith({ route: "users", params: {} });
111
+ });
112
+
113
+ it("should return empty array if get returns null", async () => {
114
+ getMock.mockResolvedValue(null);
115
+ const users = await client.getUsers();
116
+ expect(users).toEqual([]);
117
+ });
118
+
119
+ it("should translate options into query params", async () => {
120
+ await client.getUsers({
121
+ page: "1",
122
+ limit: "20",
123
+ nameMatches: "Tommus",
124
+ });
125
+ expect(getMock).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ params: {
128
+ page: "1",
129
+ limit: "20",
130
+ "search[name_matches]": "Tommus",
131
+ },
132
+ }),
133
+ );
134
+ });
135
+
136
+ it("should transform the API users to client users", async () => {
137
+ const usersResponse = [
138
+ createApiUser({ id: 1, name: "Zackary911" }),
139
+ createApiUser({ id: 2, name: "Braeburned" }),
140
+ createApiUser({ id: 3, name: "LetoDoesArt" }),
141
+ ];
142
+ getMock.mockResolvedValue(usersResponse);
143
+
144
+ const users = await client.getUsers();
145
+ expect(users).toEqual([
146
+ createUser({ id: 1, name: "Zackary911" }),
147
+ createUser({ id: 2, name: "Braeburned" }),
148
+ createUser({ id: 3, name: "LetoDoesArt" }),
149
+ ]);
150
+ });
151
+ });
152
+
153
+ describe("getUserById", () => {
154
+ it("should use the correct default values", async () => {
155
+ await client.getUserById(123);
156
+ expect(getMock).toHaveBeenCalledWith({ route: ["users", 123] });
157
+ });
158
+
159
+ it("should return null if no user is found", async () => {
160
+ getMock.mockResolvedValue(null);
161
+ const user = await client.getUserById(123);
162
+ expect(user).toBeNull();
163
+ });
164
+
165
+ it("should transform the API user to client user", async () => {
166
+ const userResponse = createApiUser({ id: 123 });
167
+ getMock.mockResolvedValue(userResponse);
168
+
169
+ const user = await client.getUserById(123);
170
+ expect(user).toEqual(createUser({ id: 123 }));
171
+ });
172
+ });
173
+
174
+ describe("getPosts", () => {
175
+ it("should use the correct default values", async () => {
176
+ await client.getPosts();
177
+ expect(getMock).toHaveBeenCalledWith({ route: "posts", params: {} });
178
+ });
179
+
180
+ it("should return empty array if get returns null", async () => {
181
+ getMock.mockResolvedValue(null);
182
+ const posts = await client.getPosts();
183
+ expect(posts).toEqual([]);
184
+ });
185
+
186
+ it("should transform the API posts to client posts", async () => {
187
+ const postsResponse: GetPostsResponse = {
188
+ posts: [
189
+ createApiPost({ id: 1 }),
190
+ createApiPost({ id: 2 }),
191
+ createApiPost({ id: 3 }),
192
+ ],
193
+ };
194
+ getMock.mockResolvedValue(postsResponse);
195
+
196
+ const posts = await client.getPosts();
197
+ expect(posts).toEqual([
198
+ createPost({ id: 1 }),
199
+ createPost({ id: 2 }),
200
+ createPost({ id: 3 }),
201
+ ]);
202
+ });
203
+ });
204
+
205
+ describe("getPostById", () => {
206
+ it("should use the correct default values", async () => {
207
+ await client.getPostById(123);
208
+ expect(getMock).toHaveBeenCalledWith({ route: ["posts", 123] });
209
+ });
210
+
211
+ it("should return null if no post is found", async () => {
212
+ getMock.mockResolvedValue(null);
213
+ const post = await client.getPostById(123);
214
+ expect(post).toBeNull();
215
+ });
216
+
217
+ it("should transform the API post to client post", async () => {
218
+ const postsResponse: GetPostResponse = {
219
+ post: createApiPost({ id: 123 }),
220
+ };
221
+ getMock.mockResolvedValue(postsResponse);
222
+
223
+ const post = await client.getPostById(123);
224
+ expect(post).toEqual(createPost({ id: 123 }));
225
+ });
226
+ });
227
+
228
+ describe("getFavorites", () => {
229
+ it("should use the correct default values", async () => {
230
+ await client.getFavorites();
231
+ expect(getMock).toHaveBeenCalledWith({
232
+ route: "favorites",
233
+ params: {},
234
+ });
235
+ });
236
+
237
+ it("should return empty array if get returns null", async () => {
238
+ getMock.mockResolvedValue(null);
239
+ const posts = await client.getFavorites();
240
+ expect(posts).toEqual([]);
241
+ });
242
+
243
+ it("should transform the API posts to client posts", async () => {
244
+ const postsResponse: GetFavoritesResponse = {
245
+ posts: [
246
+ createApiPost({ id: 1 }),
247
+ createApiPost({ id: 2 }),
248
+ createApiPost({ id: 3 }),
249
+ ],
250
+ };
251
+ getMock.mockResolvedValue(postsResponse);
252
+
253
+ const posts = await client.getFavorites();
254
+ expect(posts).toEqual([
255
+ createPost({ id: 1 }),
256
+ createPost({ id: 2 }),
257
+ createPost({ id: 3 }),
258
+ ]);
259
+ });
260
+ });
261
+
262
+ describe("getAllFavorites", () => {
263
+ it("should call getFavorites until final page", async () => {
264
+ let page = 0;
265
+ const lastPage = 3;
266
+
267
+ const apiClient = createClient();
268
+ const getFavoritesMock = jest
269
+ .spyOn(apiClient, "getFavorites")
270
+ .mockImplementation(() => {
271
+ return page++ === lastPage
272
+ ? Promise.resolve([])
273
+ : Promise.resolve([createPost()]);
274
+ });
275
+
276
+ const favorites = await apiClient.getAllFavorites();
277
+ expect(favorites).toEqual([createPost(), createPost(), createPost()]);
278
+ expect(getFavoritesMock).toHaveBeenCalledTimes(lastPage + 1);
279
+ });
280
+
281
+ it("wait between getFavorite calls with the given rate limit", async () => {
282
+ let page = 0;
283
+ const lastPage = 5;
284
+
285
+ const apiClient = createClient({ batchRateLimit: 100 });
286
+ const getFavoritesMock = jest
287
+ .spyOn(apiClient, "getFavorites")
288
+ .mockImplementation(() => {
289
+ return page++ === lastPage
290
+ ? Promise.resolve([])
291
+ : Promise.resolve([createPost()]);
292
+ });
293
+
294
+ await new Promise(async (res) => {
295
+ apiClient.getAllFavorites().then(res);
296
+
297
+ // wait until 4 calls have been made (1 on start, and 1 each 100 after that) - 50ms grace period for timeout
298
+ delay(350).then(() => {
299
+ expect(getFavoritesMock).toHaveBeenCalledTimes(4);
300
+ });
301
+ });
302
+ });
303
+ });
304
+ });
305
+ });