@thalorlabs/jokeapi 1.0.1 → 1.1.1

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/.prettierrc CHANGED
@@ -1,7 +1 @@
1
- {
2
- "semi": true,
3
- "singleQuote": true,
4
- "trailingComma": "es5",
5
- "printWidth": 80,
6
- "tabWidth": 2
7
- }
1
+ "@thalorlabs/eslint-plugin-dev-config/prettier"
@@ -1,3 +1,4 @@
1
+ import { BaseHttpClient } from '@thalorlabs/api';
1
2
  import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
2
3
  export interface JokeApiClientConfig {
3
4
  baseURL?: string;
@@ -11,16 +12,13 @@ export interface JokeApiQueryParams {
11
12
  /**
12
13
  * HTTP adapter for v2.jokeapi.dev.
13
14
  *
14
- * Calls the external API and normalises the raw response to TLJoke.
15
- * Supports both single and twopart jokes, category filtering, and safe mode.
15
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
16
+ * observability. Supports both single and twopart jokes, category
17
+ * filtering, and safe mode. Normalises the raw response to TLJoke.
16
18
  * No DB, no cache, no logging — pure HTTP adapter.
17
- *
18
- * When @thalorlabs/api is available, this should extend BaseHttpClient
19
- * instead of managing its own axios instance.
20
19
  */
21
- export declare class JokeApiClient {
20
+ export declare class JokeApiClient extends BaseHttpClient {
22
21
  readonly serviceName = "jokeapi";
23
- private readonly axiosInstance;
24
22
  constructor(config?: JokeApiClientConfig);
25
23
  getJoke(params?: JokeApiQueryParams): Promise<TLJoke>;
26
24
  private normalise;
@@ -1,11 +1,8 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.JokeApiClient = void 0;
7
- const axios_1 = __importDefault(require("axios"));
8
4
  const crypto_1 = require("crypto");
5
+ const api_1 = require("@thalorlabs/api");
9
6
  const types_1 = require("@thalorlabs/types");
10
7
  /** Maps TL categories to JokeAPI v2 category strings. */
11
8
  const CATEGORY_MAP = {
@@ -29,23 +26,20 @@ const REVERSE_CATEGORY_MAP = {
29
26
  /**
30
27
  * HTTP adapter for v2.jokeapi.dev.
31
28
  *
32
- * Calls the external API and normalises the raw response to TLJoke.
33
- * Supports both single and twopart jokes, category filtering, and safe mode.
29
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
30
+ * observability. Supports both single and twopart jokes, category
31
+ * filtering, and safe mode. Normalises the raw response to TLJoke.
34
32
  * No DB, no cache, no logging — pure HTTP adapter.
35
- *
36
- * When @thalorlabs/api is available, this should extend BaseHttpClient
37
- * instead of managing its own axios instance.
38
33
  */
39
- class JokeApiClient {
34
+ class JokeApiClient extends api_1.BaseHttpClient {
40
35
  constructor(config = {}) {
41
- this.serviceName = 'jokeapi';
42
- this.axiosInstance = axios_1.default.create({
36
+ super({
43
37
  baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
38
+ serviceName: 'jokeapi',
39
+ retries: 3,
44
40
  timeout: config.timeout ?? 10000,
45
- headers: {
46
- Accept: 'application/json',
47
- },
48
41
  });
42
+ this.serviceName = 'jokeapi';
49
43
  }
50
44
  async getJoke(params = {}) {
51
45
  const category = params.category
@@ -59,7 +53,9 @@ class JokeApiClient {
59
53
  if (params.safe) {
60
54
  queryParams.safe = 'true';
61
55
  }
62
- const { data } = await this.axiosInstance.get(`/joke/${category}`, { params: queryParams });
56
+ const url = `/joke/${category}`;
57
+ const { response } = await this.handleRequest({ method: 'GET', url, params: queryParams }, { method: 'GET', url, requestId: (0, crypto_1.randomUUID)() });
58
+ const data = response.data;
63
59
  if (data.error) {
64
60
  throw new Error(`JokeAPI error: ${data.message}`);
65
61
  }
package/eslint.config.mjs CHANGED
@@ -1,21 +1,3 @@
1
- import eslint from '@eslint/js';
2
- import tseslint from 'typescript-eslint';
3
- import prettier from 'eslint-config-prettier';
4
- import nodePlugin from 'eslint-plugin-n';
1
+ import { backend } from '@thalorlabs/eslint-plugin-dev-config';
5
2
 
6
- export default tseslint.config(
7
- eslint.configs.recommended,
8
- ...tseslint.configs.recommended,
9
- nodePlugin.configs['flat/recommended-module'],
10
- prettier,
11
- {
12
- rules: {
13
- '@typescript-eslint/no-explicit-any': 'error',
14
- 'n/no-missing-import': 'off',
15
- 'n/no-unpublished-import': 'off',
16
- },
17
- },
18
- {
19
- ignores: ['dist/', 'node_modules/'],
20
- }
21
- );
3
+ export default backend;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thalorlabs/jokeapi",
3
3
  "author": "ThalorLabs",
4
4
  "private": false,
5
- "version": "1.0.1",
5
+ "version": "1.1.1",
6
6
  "description": "Provider adapter for v2.jokeapi.dev — returns TLJoke",
7
7
  "homepage": "https://github.com/ThalorLabs/jokeapi#readme",
8
8
  "bugs": {
@@ -28,19 +28,15 @@
28
28
  "format:check": "prettier --check \"src/**/*.ts\""
29
29
  },
30
30
  "devDependencies": {
31
- "@eslint/js": "^10.0.1",
31
+ "@thalorlabs/eslint-plugin-dev-config": "^2.1.1",
32
32
  "@types/node": "^25.5.2",
33
- "eslint": "^10.1.0",
34
- "eslint-config-prettier": "^10.1.8",
35
- "eslint-plugin-n": "^17.24.0",
36
33
  "prettier": "^3.8.1",
37
34
  "rimraf": "^5.0.0",
38
35
  "typescript": "^5.0.0",
39
- "typescript-eslint": "^8.58.0",
40
36
  "vitest": "^3.0.0"
41
37
  },
42
38
  "dependencies": {
43
- "@thalorlabs/types": "^1.7.0",
44
- "axios": "^1.7.0"
39
+ "@thalorlabs/api": "^1.0.0",
40
+ "@thalorlabs/types": "^1.7.0"
45
41
  }
46
42
  }
@@ -1,5 +1,5 @@
1
- import axios, { AxiosInstance } from 'axios';
2
- import { createHash } from 'crypto';
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { BaseHttpClient } from '@thalorlabs/api';
3
3
  import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
4
4
  import { JokeApiRawResponse, JokeApiErrorResponse } from './JokeApiTypes';
5
5
 
@@ -38,24 +38,20 @@ const REVERSE_CATEGORY_MAP: Record<string, EJokeCategory> = {
38
38
  /**
39
39
  * HTTP adapter for v2.jokeapi.dev.
40
40
  *
41
- * Calls the external API and normalises the raw response to TLJoke.
42
- * Supports both single and twopart jokes, category filtering, and safe mode.
41
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
42
+ * observability. Supports both single and twopart jokes, category
43
+ * filtering, and safe mode. Normalises the raw response to TLJoke.
43
44
  * No DB, no cache, no logging — pure HTTP adapter.
44
- *
45
- * When @thalorlabs/api is available, this should extend BaseHttpClient
46
- * instead of managing its own axios instance.
47
45
  */
48
- export class JokeApiClient {
46
+ export class JokeApiClient extends BaseHttpClient {
49
47
  public readonly serviceName = 'jokeapi';
50
- private readonly axiosInstance: AxiosInstance;
51
48
 
52
49
  constructor(config: JokeApiClientConfig = {}) {
53
- this.axiosInstance = axios.create({
50
+ super({
54
51
  baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
52
+ serviceName: 'jokeapi',
53
+ retries: 3,
55
54
  timeout: config.timeout ?? 10000,
56
- headers: {
57
- Accept: 'application/json',
58
- },
59
55
  });
60
56
  }
61
57
 
@@ -75,9 +71,15 @@ export class JokeApiClient {
75
71
  queryParams.safe = 'true';
76
72
  }
77
73
 
78
- const { data } = await this.axiosInstance.get<
74
+ const url = `/joke/${category}`;
75
+ const { response } = await this.handleRequest<
79
76
  JokeApiRawResponse | JokeApiErrorResponse
80
- >(`/joke/${category}`, { params: queryParams });
77
+ >(
78
+ { method: 'GET', url, params: queryParams },
79
+ { method: 'GET', url, requestId: randomUUID() }
80
+ );
81
+
82
+ const data = response.data;
81
83
 
82
84
  if (data.error) {
83
85
  throw new Error(
@@ -1,24 +1,12 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { JokeApiClient } from '../src/JokeApiClient';
3
3
  import { EJokeType, EJokeCategory } from '@thalorlabs/types';
4
- import axios from 'axios';
5
-
6
- vi.mock('axios', () => {
7
- const mockAxiosInstance = {
8
- get: vi.fn(),
9
- };
10
- return {
11
- default: {
12
- create: vi.fn(() => mockAxiosInstance),
13
- },
14
- };
15
- });
4
+ import { BaseHttpClient } from '@thalorlabs/api';
16
5
 
17
- function getMockAxios() {
18
- const instance = (axios.create as ReturnType<typeof vi.fn>).mock.results[0]
19
- .value;
20
- return instance.get as ReturnType<typeof vi.fn>;
21
- }
6
+ const mockHandleRequest = vi.fn();
7
+ vi.spyOn(BaseHttpClient.prototype as any, 'handleRequest').mockImplementation(
8
+ mockHandleRequest
9
+ );
22
10
 
23
11
  describe('JokeApiClient', () => {
24
12
  let client: JokeApiClient;
@@ -32,37 +20,24 @@ describe('JokeApiClient', () => {
32
20
  it('sets serviceName to jokeapi', () => {
33
21
  expect(client.serviceName).toBe('jokeapi');
34
22
  });
35
-
36
- it('creates axios instance with default config', () => {
37
- expect(axios.create).toHaveBeenCalledWith({
38
- baseURL: 'https://v2.jokeapi.dev',
39
- timeout: 10000,
40
- headers: { Accept: 'application/json' },
41
- });
42
- });
43
23
  });
44
24
 
45
25
  describe('getJoke — single joke', () => {
46
26
  it('normalises a single joke to TLJoke', async () => {
47
- const mockGet = getMockAxios();
48
- mockGet.mockResolvedValueOnce({
49
- data: {
50
- error: false,
51
- category: 'Programming',
52
- type: 'single',
53
- joke: 'A SQL query walks into a bar, walks up to two tables and asks... can I join you?',
54
- flags: {
55
- nsfw: false,
56
- religious: false,
57
- political: false,
58
- racist: false,
59
- sexist: false,
60
- explicit: false,
27
+ mockHandleRequest.mockResolvedValueOnce({
28
+ response: {
29
+ data: {
30
+ error: false,
31
+ category: 'Programming',
32
+ type: 'single',
33
+ joke: 'A SQL query walks into a bar.',
34
+ flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false },
35
+ id: 42,
36
+ safe: true,
37
+ lang: 'en',
61
38
  },
62
- id: 42,
63
- safe: true,
64
- lang: 'en',
65
39
  },
40
+ durationMs: 50,
66
41
  });
67
42
 
68
43
  const joke = await client.getJoke();
@@ -79,26 +54,21 @@ describe('JokeApiClient', () => {
79
54
 
80
55
  describe('getJoke — twopart joke', () => {
81
56
  it('normalises a twopart joke to TLJoke', async () => {
82
- const mockGet = getMockAxios();
83
- mockGet.mockResolvedValueOnce({
84
- data: {
85
- error: false,
86
- category: 'Programming',
87
- type: 'twopart',
88
- setup: 'Why do programmers prefer dark mode?',
89
- delivery: 'Because light attracts bugs.',
90
- flags: {
91
- nsfw: false,
92
- religious: false,
93
- political: false,
94
- racist: false,
95
- sexist: false,
96
- explicit: false,
57
+ mockHandleRequest.mockResolvedValueOnce({
58
+ response: {
59
+ data: {
60
+ error: false,
61
+ category: 'Programming',
62
+ type: 'twopart',
63
+ setup: 'Why do programmers prefer dark mode?',
64
+ delivery: 'Because light attracts bugs.',
65
+ flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false },
66
+ id: 58,
67
+ safe: true,
68
+ lang: 'en',
97
69
  },
98
- id: 58,
99
- safe: true,
100
- lang: 'en',
101
70
  },
71
+ durationMs: 50,
102
72
  });
103
73
 
104
74
  const joke = await client.getJoke();
@@ -114,104 +84,83 @@ describe('JokeApiClient', () => {
114
84
 
115
85
  describe('getJoke — category mapping', () => {
116
86
  it('maps PROGRAMMING category to Programming endpoint', async () => {
117
- const mockGet = getMockAxios();
118
- mockGet.mockResolvedValueOnce({
119
- data: {
120
- error: false,
121
- category: 'Programming',
122
- type: 'single',
123
- joke: 'A joke',
124
- flags: {
125
- nsfw: false,
126
- religious: false,
127
- political: false,
128
- racist: false,
129
- sexist: false,
130
- explicit: false,
87
+ mockHandleRequest.mockResolvedValueOnce({
88
+ response: {
89
+ data: {
90
+ error: false, category: 'Programming', type: 'single', joke: 'A joke',
91
+ flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false },
92
+ id: 1, safe: true, lang: 'en',
131
93
  },
132
- id: 1,
133
- safe: true,
134
- lang: 'en',
135
94
  },
95
+ durationMs: 10,
136
96
  });
137
97
 
138
98
  await client.getJoke({ category: EJokeCategory.PROGRAMMING });
139
99
 
140
- expect(mockGet).toHaveBeenCalledWith('/joke/Programming', { params: {} });
100
+ expect(mockHandleRequest).toHaveBeenCalledWith(
101
+ expect.objectContaining({ url: '/joke/Programming' }),
102
+ expect.objectContaining({ url: '/joke/Programming' })
103
+ );
141
104
  });
142
105
 
143
106
  it('uses Any when no category specified', async () => {
144
- const mockGet = getMockAxios();
145
- mockGet.mockResolvedValueOnce({
146
- data: {
147
- error: false,
148
- category: 'Misc',
149
- type: 'single',
150
- joke: 'A joke',
151
- flags: {
152
- nsfw: false,
153
- religious: false,
154
- political: false,
155
- racist: false,
156
- sexist: false,
157
- explicit: false,
107
+ mockHandleRequest.mockResolvedValueOnce({
108
+ response: {
109
+ data: {
110
+ error: false, category: 'Misc', type: 'single', joke: 'A joke',
111
+ flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false },
112
+ id: 1, safe: true, lang: 'en',
158
113
  },
159
- id: 1,
160
- safe: true,
161
- lang: 'en',
162
114
  },
115
+ durationMs: 10,
163
116
  });
164
117
 
165
118
  await client.getJoke();
166
119
 
167
- expect(mockGet).toHaveBeenCalledWith('/joke/Any', { params: {} });
120
+ expect(mockHandleRequest).toHaveBeenCalledWith(
121
+ expect.objectContaining({ url: '/joke/Any' }),
122
+ expect.objectContaining({ url: '/joke/Any' })
123
+ );
168
124
  });
169
125
  });
170
126
 
171
127
  describe('getJoke — safe mode', () => {
172
128
  it('passes safe=true query param when requested', async () => {
173
- const mockGet = getMockAxios();
174
- mockGet.mockResolvedValueOnce({
175
- data: {
176
- error: false,
177
- category: 'Misc',
178
- type: 'single',
179
- joke: 'A joke',
180
- flags: {
181
- nsfw: false,
182
- religious: false,
183
- political: false,
184
- racist: false,
185
- sexist: false,
186
- explicit: false,
129
+ mockHandleRequest.mockResolvedValueOnce({
130
+ response: {
131
+ data: {
132
+ error: false, category: 'Misc', type: 'single', joke: 'A joke',
133
+ flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false },
134
+ id: 1, safe: true, lang: 'en',
187
135
  },
188
- id: 1,
189
- safe: true,
190
- lang: 'en',
191
136
  },
137
+ durationMs: 10,
192
138
  });
193
139
 
194
140
  await client.getJoke({ safe: true });
195
141
 
196
- expect(mockGet).toHaveBeenCalledWith('/joke/Any', {
197
- params: { safe: 'true' },
198
- });
142
+ expect(mockHandleRequest).toHaveBeenCalledWith(
143
+ expect.objectContaining({ params: { safe: 'true' } }),
144
+ expect.anything()
145
+ );
199
146
  });
200
147
  });
201
148
 
202
149
  describe('getJoke — error handling', () => {
203
150
  it('throws on JokeAPI error response', async () => {
204
- const mockGet = getMockAxios();
205
- mockGet.mockResolvedValueOnce({
206
- data: {
207
- error: true,
208
- internalError: false,
209
- code: 106,
210
- message: 'No matching joke found',
211
- causedBy: ['No jokes found'],
212
- additionalInfo: '',
213
- timestamp: Date.now(),
151
+ mockHandleRequest.mockResolvedValueOnce({
152
+ response: {
153
+ data: {
154
+ error: true,
155
+ internalError: false,
156
+ code: 106,
157
+ message: 'No matching joke found',
158
+ causedBy: ['No jokes found'],
159
+ additionalInfo: '',
160
+ timestamp: Date.now(),
161
+ },
214
162
  },
163
+ durationMs: 10,
215
164
  });
216
165
 
217
166
  await expect(client.getJoke()).rejects.toThrow(
@@ -219,9 +168,8 @@ describe('JokeApiClient', () => {
219
168
  );
220
169
  });
221
170
 
222
- it('propagates network errors', async () => {
223
- const mockGet = getMockAxios();
224
- mockGet.mockRejectedValueOnce(new Error('Network error'));
171
+ it('propagates errors from handleRequest', async () => {
172
+ mockHandleRequest.mockRejectedValueOnce(new Error('Network error'));
225
173
 
226
174
  await expect(client.getJoke()).rejects.toThrow('Network error');
227
175
  });
package/tsconfig.json CHANGED
@@ -1,13 +1,7 @@
1
1
  {
2
+ "extends": "@thalorlabs/eslint-plugin-dev-config/tsconfig/backend.json",
2
3
  "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "CommonJS",
5
- "declaration": true,
6
- "outDir": "./dist",
7
- "strict": true,
8
- "esModuleInterop": true,
9
- "skipLibCheck": true,
10
- "moduleResolution": "node"
4
+ "outDir": "./dist"
11
5
  },
12
6
  "include": ["src"],
13
7
  "exclude": ["node_modules", "dist"]