@thalorlabs/jokeapi 1.0.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/.env.example +6 -0
- package/.prettierrc +7 -0
- package/CLAUDE.md +53 -0
- package/README.md +62 -0
- package/dist/JokeApiClient.d.ts +27 -0
- package/dist/JokeApiClient.js +93 -0
- package/dist/JokeApiTypes.d.ts +45 -0
- package/dist/JokeApiTypes.js +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/eslint.config.mjs +21 -0
- package/package.json +46 -0
- package/src/JokeApiClient.ts +118 -0
- package/src/JokeApiTypes.ts +50 -0
- package/src/index.ts +10 -0
- package/tests/JokeApiClient.test.ts +229 -0
- package/tsconfig.json +14 -0
package/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# @thalorlabs/jokeapi
|
|
2
|
+
# No environment variables required — v2.jokeapi.dev is free and unauthenticated.
|
|
3
|
+
# This file exists for consistency with other provider packages.
|
|
4
|
+
|
|
5
|
+
# Optional: Override the API base URL (e.g. for testing with a mock server)
|
|
6
|
+
# JOKE_API_BASE_URL=https://v2.jokeapi.dev
|
package/.prettierrc
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @thalorlabs/jokeapi
|
|
2
|
+
|
|
3
|
+
## What this repo is
|
|
4
|
+
|
|
5
|
+
Provider adapter package for v2.jokeapi.dev. Calls one external API, normalises the response to `TLJoke`, and returns it. No DB, no cache, no logging.
|
|
6
|
+
|
|
7
|
+
## Knowledge base
|
|
8
|
+
|
|
9
|
+
Before writing any code, read:
|
|
10
|
+
- TL-Coding vault: `knowledge/multi-provider-pattern.md`
|
|
11
|
+
- TL-Coding vault: `examples/jokes/layer-provider-package.md`
|
|
12
|
+
- TL-Coding vault: `context/packages.md`
|
|
13
|
+
|
|
14
|
+
## External API
|
|
15
|
+
|
|
16
|
+
- **API:** v2.jokeapi.dev
|
|
17
|
+
- **Auth:** None
|
|
18
|
+
- **Billable:** No
|
|
19
|
+
- **Joke types:** SINGLE and TWOPART
|
|
20
|
+
- **Categories:** Programming, Dark, Pun, Spooky, Christmas, Misc
|
|
21
|
+
- **Safe mode:** Supported via `safe` query param
|
|
22
|
+
|
|
23
|
+
## Build & publish
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run build # tsc → dist/
|
|
27
|
+
npm test # vitest
|
|
28
|
+
npm version patch # or minor/major
|
|
29
|
+
npm publish --access public
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Folder structure
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
index.ts ← exports JokeApiClient, JOKE_API_CONFIG
|
|
37
|
+
JokeApiClient.ts ← HTTP adapter, normalises to TLJoke
|
|
38
|
+
JokeApiTypes.ts ← raw API types, local only, never exported
|
|
39
|
+
tests/
|
|
40
|
+
JokeApiClient.test.ts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What this package does NOT do
|
|
44
|
+
|
|
45
|
+
- No database access
|
|
46
|
+
- No caching (orchestrator handles this)
|
|
47
|
+
- No logging (orchestrator handles this)
|
|
48
|
+
- Raw types never exported, never in `@thalorlabs/types`
|
|
49
|
+
- No provider selection logic — that's the orchestrator's job
|
|
50
|
+
|
|
51
|
+
## Migration note
|
|
52
|
+
|
|
53
|
+
When `@thalorlabs/api` is published, `JokeApiClient` should extend `BaseHttpClient` instead of managing its own axios instance.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @thalorlabs/jokeapi
|
|
2
|
+
|
|
3
|
+
Provider adapter for [v2.jokeapi.dev](https://v2.jokeapi.dev). Returns normalised `TLJoke` from `@thalorlabs/types`. Supports both single and twopart jokes with category filtering and safe mode.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @thalorlabs/jokeapi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { JokeApiClient, JOKE_API_CONFIG } from '@thalorlabs/jokeapi';
|
|
15
|
+
import { EJokeCategory, EJokeType } from '@thalorlabs/types';
|
|
16
|
+
|
|
17
|
+
const client = new JokeApiClient();
|
|
18
|
+
|
|
19
|
+
// Random joke
|
|
20
|
+
const joke = await client.getJoke();
|
|
21
|
+
|
|
22
|
+
// Filtered by category and type
|
|
23
|
+
const progJoke = await client.getJoke({
|
|
24
|
+
category: EJokeCategory.PROGRAMMING,
|
|
25
|
+
type: EJokeType.TWOPART,
|
|
26
|
+
safe: true,
|
|
27
|
+
});
|
|
28
|
+
// → TLJoke { id, type: 'TWOPART', setup: '...', delivery: '...', category: 'PROGRAMMING', safe: true, provider: 'jokeapi' }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
| Option | Default | Description |
|
|
34
|
+
|--------|---------|-------------|
|
|
35
|
+
| `baseURL` | `https://v2.jokeapi.dev` | API base URL |
|
|
36
|
+
| `timeout` | `10000` | Request timeout (ms) |
|
|
37
|
+
|
|
38
|
+
## Query Parameters
|
|
39
|
+
|
|
40
|
+
| Param | Type | Description |
|
|
41
|
+
|-------|------|-------------|
|
|
42
|
+
| `category` | `EJokeCategory` | Filter by category (PROGRAMMING, DARK, PUN, SPOOKY, CHRISTMAS, GENERAL) |
|
|
43
|
+
| `type` | `EJokeType` | Filter by type (SINGLE, TWOPART) |
|
|
44
|
+
| `safe` | `boolean` | Only return safe jokes |
|
|
45
|
+
|
|
46
|
+
## Exported Config
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
JOKE_API_CONFIG = {
|
|
50
|
+
cacheTtlMs: 3600000, // 1h
|
|
51
|
+
isBillable: false,
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Scripts
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm run build # tsc → dist/
|
|
59
|
+
npm test # vitest
|
|
60
|
+
npm run lint # eslint
|
|
61
|
+
npm run format:check # prettier
|
|
62
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
|
|
2
|
+
export interface JokeApiClientConfig {
|
|
3
|
+
baseURL?: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface JokeApiQueryParams {
|
|
7
|
+
category?: EJokeCategory;
|
|
8
|
+
type?: EJokeType;
|
|
9
|
+
safe?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* HTTP adapter for v2.jokeapi.dev.
|
|
13
|
+
*
|
|
14
|
+
* Calls the external API and normalises the raw response to TLJoke.
|
|
15
|
+
* Supports both single and twopart jokes, category filtering, and safe mode.
|
|
16
|
+
* 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
|
+
*/
|
|
21
|
+
export declare class JokeApiClient {
|
|
22
|
+
readonly serviceName = "jokeapi";
|
|
23
|
+
private readonly axiosInstance;
|
|
24
|
+
constructor(config?: JokeApiClientConfig);
|
|
25
|
+
getJoke(params?: JokeApiQueryParams): Promise<TLJoke>;
|
|
26
|
+
private normalise;
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.JokeApiClient = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const types_1 = require("@thalorlabs/types");
|
|
10
|
+
/** Maps TL categories to JokeAPI v2 category strings. */
|
|
11
|
+
const CATEGORY_MAP = {
|
|
12
|
+
[types_1.EJokeCategory.PROGRAMMING]: 'Programming',
|
|
13
|
+
[types_1.EJokeCategory.DARK]: 'Dark',
|
|
14
|
+
[types_1.EJokeCategory.PUN]: 'Pun',
|
|
15
|
+
[types_1.EJokeCategory.SPOOKY]: 'Spooky',
|
|
16
|
+
[types_1.EJokeCategory.CHRISTMAS]: 'Christmas',
|
|
17
|
+
[types_1.EJokeCategory.GENERAL]: 'Misc',
|
|
18
|
+
[types_1.EJokeCategory.DAD]: 'Misc',
|
|
19
|
+
};
|
|
20
|
+
/** Maps JokeAPI v2 category strings back to TL categories. */
|
|
21
|
+
const REVERSE_CATEGORY_MAP = {
|
|
22
|
+
Programming: types_1.EJokeCategory.PROGRAMMING,
|
|
23
|
+
Dark: types_1.EJokeCategory.DARK,
|
|
24
|
+
Pun: types_1.EJokeCategory.PUN,
|
|
25
|
+
Spooky: types_1.EJokeCategory.SPOOKY,
|
|
26
|
+
Christmas: types_1.EJokeCategory.CHRISTMAS,
|
|
27
|
+
Misc: types_1.EJokeCategory.GENERAL,
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* HTTP adapter for v2.jokeapi.dev.
|
|
31
|
+
*
|
|
32
|
+
* Calls the external API and normalises the raw response to TLJoke.
|
|
33
|
+
* Supports both single and twopart jokes, category filtering, and safe mode.
|
|
34
|
+
* 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
|
+
*/
|
|
39
|
+
class JokeApiClient {
|
|
40
|
+
constructor(config = {}) {
|
|
41
|
+
this.serviceName = 'jokeapi';
|
|
42
|
+
this.axiosInstance = axios_1.default.create({
|
|
43
|
+
baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
|
|
44
|
+
timeout: config.timeout ?? 10000,
|
|
45
|
+
headers: {
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async getJoke(params = {}) {
|
|
51
|
+
const category = params.category
|
|
52
|
+
? (CATEGORY_MAP[params.category] ?? 'Any')
|
|
53
|
+
: 'Any';
|
|
54
|
+
const queryParams = {};
|
|
55
|
+
if (params.type) {
|
|
56
|
+
queryParams.type =
|
|
57
|
+
params.type === types_1.EJokeType.SINGLE ? 'single' : 'twopart';
|
|
58
|
+
}
|
|
59
|
+
if (params.safe) {
|
|
60
|
+
queryParams.safe = 'true';
|
|
61
|
+
}
|
|
62
|
+
const { data } = await this.axiosInstance.get(`/joke/${category}`, { params: queryParams });
|
|
63
|
+
if (data.error) {
|
|
64
|
+
throw new Error(`JokeAPI error: ${data.message}`);
|
|
65
|
+
}
|
|
66
|
+
return this.normalise(data);
|
|
67
|
+
}
|
|
68
|
+
normalise(raw) {
|
|
69
|
+
const resolvedCategory = REVERSE_CATEGORY_MAP[raw.category] ?? types_1.EJokeCategory.GENERAL;
|
|
70
|
+
if (raw.type === 'twopart') {
|
|
71
|
+
return types_1.TLJoke.parse({
|
|
72
|
+
id: (0, crypto_1.createHash)('md5')
|
|
73
|
+
.update(raw.setup + raw.delivery)
|
|
74
|
+
.digest('hex'),
|
|
75
|
+
type: types_1.EJokeType.TWOPART,
|
|
76
|
+
setup: raw.setup,
|
|
77
|
+
delivery: raw.delivery,
|
|
78
|
+
category: resolvedCategory,
|
|
79
|
+
safe: raw.safe,
|
|
80
|
+
provider: this.serviceName,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return types_1.TLJoke.parse({
|
|
84
|
+
id: (0, crypto_1.createHash)('md5').update(raw.joke).digest('hex'),
|
|
85
|
+
type: types_1.EJokeType.SINGLE,
|
|
86
|
+
joke: raw.joke,
|
|
87
|
+
category: resolvedCategory,
|
|
88
|
+
safe: raw.safe,
|
|
89
|
+
provider: this.serviceName,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
exports.JokeApiClient = JokeApiClient;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw response types from v2.jokeapi.dev.
|
|
3
|
+
*
|
|
4
|
+
* Internal to this package only — never exported from index.ts,
|
|
5
|
+
* never added to @thalorlabs/types.
|
|
6
|
+
*/
|
|
7
|
+
export interface JokeApiFlags {
|
|
8
|
+
nsfw: boolean;
|
|
9
|
+
religious: boolean;
|
|
10
|
+
political: boolean;
|
|
11
|
+
racist: boolean;
|
|
12
|
+
sexist: boolean;
|
|
13
|
+
explicit: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface JokeApiSingleRaw {
|
|
16
|
+
error: false;
|
|
17
|
+
category: string;
|
|
18
|
+
type: 'single';
|
|
19
|
+
joke: string;
|
|
20
|
+
flags: JokeApiFlags;
|
|
21
|
+
id: number;
|
|
22
|
+
safe: boolean;
|
|
23
|
+
lang: string;
|
|
24
|
+
}
|
|
25
|
+
export interface JokeApiTwoPartRaw {
|
|
26
|
+
error: false;
|
|
27
|
+
category: string;
|
|
28
|
+
type: 'twopart';
|
|
29
|
+
setup: string;
|
|
30
|
+
delivery: string;
|
|
31
|
+
flags: JokeApiFlags;
|
|
32
|
+
id: number;
|
|
33
|
+
safe: boolean;
|
|
34
|
+
lang: string;
|
|
35
|
+
}
|
|
36
|
+
export type JokeApiRawResponse = JokeApiSingleRaw | JokeApiTwoPartRaw;
|
|
37
|
+
export interface JokeApiErrorResponse {
|
|
38
|
+
error: true;
|
|
39
|
+
internalError: boolean;
|
|
40
|
+
code: number;
|
|
41
|
+
message: string;
|
|
42
|
+
causedBy: string[];
|
|
43
|
+
additionalInfo: string;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JOKE_API_CONFIG = exports.JokeApiClient = void 0;
|
|
4
|
+
var JokeApiClient_1 = require("./JokeApiClient");
|
|
5
|
+
Object.defineProperty(exports, "JokeApiClient", { enumerable: true, get: function () { return JokeApiClient_1.JokeApiClient; } });
|
|
6
|
+
exports.JOKE_API_CONFIG = {
|
|
7
|
+
cacheTtlMs: 1000 * 60 * 60, // 1h
|
|
8
|
+
isBillable: false, // jokeapi.dev is free
|
|
9
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
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';
|
|
5
|
+
|
|
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
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thalorlabs/jokeapi",
|
|
3
|
+
"author": "ThalorLabs",
|
|
4
|
+
"private": false,
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Provider adapter for v2.jokeapi.dev — returns TLJoke",
|
|
7
|
+
"homepage": "https://github.com/ThalorLabs/jokeapi#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/ThalorLabs/jokeapi/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ThalorLabs/jokeapi.git"
|
|
14
|
+
},
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"clean": "rimraf dist",
|
|
23
|
+
"prebuild": "npm run clean",
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"lint": "eslint src/",
|
|
26
|
+
"lint:fix": "eslint src/ --fix",
|
|
27
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
28
|
+
"format:check": "prettier --check \"src/**/*.ts\""
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@eslint/js": "^10.0.1",
|
|
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
|
+
"prettier": "^3.8.1",
|
|
37
|
+
"rimraf": "^5.0.0",
|
|
38
|
+
"typescript": "^5.0.0",
|
|
39
|
+
"typescript-eslint": "^8.58.0",
|
|
40
|
+
"vitest": "^3.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@thalorlabs/types": "^1.7.0",
|
|
44
|
+
"axios": "^1.7.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
|
|
4
|
+
import { JokeApiRawResponse, JokeApiErrorResponse } from './JokeApiTypes';
|
|
5
|
+
|
|
6
|
+
export interface JokeApiClientConfig {
|
|
7
|
+
baseURL?: string;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface JokeApiQueryParams {
|
|
12
|
+
category?: EJokeCategory;
|
|
13
|
+
type?: EJokeType;
|
|
14
|
+
safe?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Maps TL categories to JokeAPI v2 category strings. */
|
|
18
|
+
const CATEGORY_MAP: Partial<Record<EJokeCategory, string>> = {
|
|
19
|
+
[EJokeCategory.PROGRAMMING]: 'Programming',
|
|
20
|
+
[EJokeCategory.DARK]: 'Dark',
|
|
21
|
+
[EJokeCategory.PUN]: 'Pun',
|
|
22
|
+
[EJokeCategory.SPOOKY]: 'Spooky',
|
|
23
|
+
[EJokeCategory.CHRISTMAS]: 'Christmas',
|
|
24
|
+
[EJokeCategory.GENERAL]: 'Misc',
|
|
25
|
+
[EJokeCategory.DAD]: 'Misc',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Maps JokeAPI v2 category strings back to TL categories. */
|
|
29
|
+
const REVERSE_CATEGORY_MAP: Record<string, EJokeCategory> = {
|
|
30
|
+
Programming: EJokeCategory.PROGRAMMING,
|
|
31
|
+
Dark: EJokeCategory.DARK,
|
|
32
|
+
Pun: EJokeCategory.PUN,
|
|
33
|
+
Spooky: EJokeCategory.SPOOKY,
|
|
34
|
+
Christmas: EJokeCategory.CHRISTMAS,
|
|
35
|
+
Misc: EJokeCategory.GENERAL,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* HTTP adapter for v2.jokeapi.dev.
|
|
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.
|
|
43
|
+
* 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
|
+
*/
|
|
48
|
+
export class JokeApiClient {
|
|
49
|
+
public readonly serviceName = 'jokeapi';
|
|
50
|
+
private readonly axiosInstance: AxiosInstance;
|
|
51
|
+
|
|
52
|
+
constructor(config: JokeApiClientConfig = {}) {
|
|
53
|
+
this.axiosInstance = axios.create({
|
|
54
|
+
baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
|
|
55
|
+
timeout: config.timeout ?? 10000,
|
|
56
|
+
headers: {
|
|
57
|
+
Accept: 'application/json',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getJoke(params: JokeApiQueryParams = {}): Promise<TLJoke> {
|
|
63
|
+
const category = params.category
|
|
64
|
+
? (CATEGORY_MAP[params.category] ?? 'Any')
|
|
65
|
+
: 'Any';
|
|
66
|
+
|
|
67
|
+
const queryParams: Record<string, string> = {};
|
|
68
|
+
|
|
69
|
+
if (params.type) {
|
|
70
|
+
queryParams.type =
|
|
71
|
+
params.type === EJokeType.SINGLE ? 'single' : 'twopart';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (params.safe) {
|
|
75
|
+
queryParams.safe = 'true';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { data } = await this.axiosInstance.get<
|
|
79
|
+
JokeApiRawResponse | JokeApiErrorResponse
|
|
80
|
+
>(`/joke/${category}`, { params: queryParams });
|
|
81
|
+
|
|
82
|
+
if (data.error) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`JokeAPI error: ${(data as JokeApiErrorResponse).message}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this.normalise(data as JokeApiRawResponse);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private normalise(raw: JokeApiRawResponse): TLJoke {
|
|
92
|
+
const resolvedCategory =
|
|
93
|
+
REVERSE_CATEGORY_MAP[raw.category] ?? EJokeCategory.GENERAL;
|
|
94
|
+
|
|
95
|
+
if (raw.type === 'twopart') {
|
|
96
|
+
return TLJoke.parse({
|
|
97
|
+
id: createHash('md5')
|
|
98
|
+
.update(raw.setup + raw.delivery)
|
|
99
|
+
.digest('hex'),
|
|
100
|
+
type: EJokeType.TWOPART,
|
|
101
|
+
setup: raw.setup,
|
|
102
|
+
delivery: raw.delivery,
|
|
103
|
+
category: resolvedCategory,
|
|
104
|
+
safe: raw.safe,
|
|
105
|
+
provider: this.serviceName,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return TLJoke.parse({
|
|
110
|
+
id: createHash('md5').update(raw.joke).digest('hex'),
|
|
111
|
+
type: EJokeType.SINGLE,
|
|
112
|
+
joke: raw.joke,
|
|
113
|
+
category: resolvedCategory,
|
|
114
|
+
safe: raw.safe,
|
|
115
|
+
provider: this.serviceName,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw response types from v2.jokeapi.dev.
|
|
3
|
+
*
|
|
4
|
+
* Internal to this package only — never exported from index.ts,
|
|
5
|
+
* never added to @thalorlabs/types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface JokeApiFlags {
|
|
9
|
+
nsfw: boolean;
|
|
10
|
+
religious: boolean;
|
|
11
|
+
political: boolean;
|
|
12
|
+
racist: boolean;
|
|
13
|
+
sexist: boolean;
|
|
14
|
+
explicit: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JokeApiSingleRaw {
|
|
18
|
+
error: false;
|
|
19
|
+
category: string;
|
|
20
|
+
type: 'single';
|
|
21
|
+
joke: string;
|
|
22
|
+
flags: JokeApiFlags;
|
|
23
|
+
id: number;
|
|
24
|
+
safe: boolean;
|
|
25
|
+
lang: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JokeApiTwoPartRaw {
|
|
29
|
+
error: false;
|
|
30
|
+
category: string;
|
|
31
|
+
type: 'twopart';
|
|
32
|
+
setup: string;
|
|
33
|
+
delivery: string;
|
|
34
|
+
flags: JokeApiFlags;
|
|
35
|
+
id: number;
|
|
36
|
+
safe: boolean;
|
|
37
|
+
lang: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type JokeApiRawResponse = JokeApiSingleRaw | JokeApiTwoPartRaw;
|
|
41
|
+
|
|
42
|
+
export interface JokeApiErrorResponse {
|
|
43
|
+
error: true;
|
|
44
|
+
internalError: boolean;
|
|
45
|
+
code: number;
|
|
46
|
+
message: string;
|
|
47
|
+
causedBy: string[];
|
|
48
|
+
additionalInfo: string;
|
|
49
|
+
timestamp: number;
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { JokeApiClient } from '../src/JokeApiClient';
|
|
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
|
+
});
|
|
16
|
+
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('JokeApiClient', () => {
|
|
24
|
+
let client: JokeApiClient;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
client = new JokeApiClient();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('constructor', () => {
|
|
32
|
+
it('sets serviceName to jokeapi', () => {
|
|
33
|
+
expect(client.serviceName).toBe('jokeapi');
|
|
34
|
+
});
|
|
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
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getJoke — single joke', () => {
|
|
46
|
+
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,
|
|
61
|
+
},
|
|
62
|
+
id: 42,
|
|
63
|
+
safe: true,
|
|
64
|
+
lang: 'en',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const joke = await client.getJoke();
|
|
69
|
+
|
|
70
|
+
expect(joke.type).toBe(EJokeType.SINGLE);
|
|
71
|
+
expect(joke.category).toBe(EJokeCategory.PROGRAMMING);
|
|
72
|
+
expect(joke.joke).toBeDefined();
|
|
73
|
+
expect(joke.setup).toBeUndefined();
|
|
74
|
+
expect(joke.delivery).toBeUndefined();
|
|
75
|
+
expect(joke.safe).toBe(true);
|
|
76
|
+
expect(joke.provider).toBe('jokeapi');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getJoke — twopart joke', () => {
|
|
81
|
+
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,
|
|
97
|
+
},
|
|
98
|
+
id: 58,
|
|
99
|
+
safe: true,
|
|
100
|
+
lang: 'en',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const joke = await client.getJoke();
|
|
105
|
+
|
|
106
|
+
expect(joke.type).toBe(EJokeType.TWOPART);
|
|
107
|
+
expect(joke.setup).toBe('Why do programmers prefer dark mode?');
|
|
108
|
+
expect(joke.delivery).toBe('Because light attracts bugs.');
|
|
109
|
+
expect(joke.joke).toBeUndefined();
|
|
110
|
+
expect(joke.category).toBe(EJokeCategory.PROGRAMMING);
|
|
111
|
+
expect(joke.provider).toBe('jokeapi');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('getJoke — category mapping', () => {
|
|
116
|
+
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,
|
|
131
|
+
},
|
|
132
|
+
id: 1,
|
|
133
|
+
safe: true,
|
|
134
|
+
lang: 'en',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await client.getJoke({ category: EJokeCategory.PROGRAMMING });
|
|
139
|
+
|
|
140
|
+
expect(mockGet).toHaveBeenCalledWith('/joke/Programming', { params: {} });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
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,
|
|
158
|
+
},
|
|
159
|
+
id: 1,
|
|
160
|
+
safe: true,
|
|
161
|
+
lang: 'en',
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await client.getJoke();
|
|
166
|
+
|
|
167
|
+
expect(mockGet).toHaveBeenCalledWith('/joke/Any', { params: {} });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('getJoke — safe mode', () => {
|
|
172
|
+
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,
|
|
187
|
+
},
|
|
188
|
+
id: 1,
|
|
189
|
+
safe: true,
|
|
190
|
+
lang: 'en',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await client.getJoke({ safe: true });
|
|
195
|
+
|
|
196
|
+
expect(mockGet).toHaveBeenCalledWith('/joke/Any', {
|
|
197
|
+
params: { safe: 'true' },
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('getJoke — error handling', () => {
|
|
203
|
+
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(),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await expect(client.getJoke()).rejects.toThrow(
|
|
218
|
+
'JokeAPI error: No matching joke found'
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('propagates network errors', async () => {
|
|
223
|
+
const mockGet = getMockAxios();
|
|
224
|
+
mockGet.mockRejectedValueOnce(new Error('Network error'));
|
|
225
|
+
|
|
226
|
+
await expect(client.getJoke()).rejects.toThrow('Network error');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"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"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"],
|
|
13
|
+
"exclude": ["node_modules", "dist"]
|
|
14
|
+
}
|