@thalorlabs/dadjoke 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 +52 -0
- package/README.md +44 -0
- package/dist/DadJokeClient.d.ts +21 -0
- package/dist/DadJokeClient.js +45 -0
- package/dist/DadJokeTypes.d.ts +11 -0
- package/dist/DadJokeTypes.js +2 -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/DadJokeClient.ts +49 -0
- package/src/DadJokeTypes.ts +11 -0
- package/src/index.ts +6 -0
- package/tests/DadJokeClient.test.ts +109 -0
- package/tsconfig.json +14 -0
package/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# @thalorlabs/dadjoke
|
|
2
|
+
# No environment variables required — icanhazdadjoke.com 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
|
+
# DAD_JOKE_BASE_URL=https://icanhazdadjoke.com
|
package/.prettierrc
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @thalorlabs/dadjoke
|
|
2
|
+
|
|
3
|
+
## What this repo is
|
|
4
|
+
|
|
5
|
+
Provider adapter package for icanhazdadjoke.com. 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:** icanhazdadjoke.com
|
|
17
|
+
- **Auth:** None
|
|
18
|
+
- **Billable:** No
|
|
19
|
+
- **Joke type:** Always SINGLE (one-liner)
|
|
20
|
+
- **Category:** Always DAD
|
|
21
|
+
|
|
22
|
+
## Build & publish
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm run build # tsc → dist/
|
|
26
|
+
npm test # vitest
|
|
27
|
+
npm version patch # or minor/major
|
|
28
|
+
npm publish --access public
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Folder structure
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
src/
|
|
35
|
+
index.ts ← exports DadJokeClient, DAD_JOKE_CONFIG
|
|
36
|
+
DadJokeClient.ts ← HTTP adapter, normalises to TLJoke
|
|
37
|
+
DadJokeTypes.ts ← raw API types, local only, never exported
|
|
38
|
+
tests/
|
|
39
|
+
DadJokeClient.test.ts
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What this package does NOT do
|
|
43
|
+
|
|
44
|
+
- No database access
|
|
45
|
+
- No caching (orchestrator handles this)
|
|
46
|
+
- No logging (orchestrator handles this)
|
|
47
|
+
- Raw types (`DadJokeRawResponse`) never exported, never in `@thalorlabs/types`
|
|
48
|
+
- No provider selection logic — that's the orchestrator's job
|
|
49
|
+
|
|
50
|
+
## Migration note
|
|
51
|
+
|
|
52
|
+
When `@thalorlabs/api` is published, `DadJokeClient` should extend `BaseHttpClient` instead of managing its own axios instance. This gives us retry logic, exponential backoff, and observability for free.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @thalorlabs/dadjoke
|
|
2
|
+
|
|
3
|
+
Provider adapter for [icanhazdadjoke.com](https://icanhazdadjoke.com). Returns normalised `TLJoke` from `@thalorlabs/types`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @thalorlabs/dadjoke
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { DadJokeClient, DAD_JOKE_CONFIG } from '@thalorlabs/dadjoke';
|
|
15
|
+
|
|
16
|
+
const client = new DadJokeClient();
|
|
17
|
+
const joke = await client.getJoke();
|
|
18
|
+
// → TLJoke { id, type: 'SINGLE', joke: '...', category: 'DAD', safe: true, provider: 'dadjoke' }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
| Option | Default | Description |
|
|
24
|
+
|--------|---------|-------------|
|
|
25
|
+
| `baseURL` | `https://icanhazdadjoke.com` | API base URL |
|
|
26
|
+
| `timeout` | `10000` | Request timeout (ms) |
|
|
27
|
+
|
|
28
|
+
## Exported Config
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
DAD_JOKE_CONFIG = {
|
|
32
|
+
cacheTtlMs: 86400000, // 24h
|
|
33
|
+
isBillable: false,
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Scripts
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run build # tsc → dist/
|
|
41
|
+
npm test # vitest
|
|
42
|
+
npm run lint # eslint
|
|
43
|
+
npm run format:check # prettier
|
|
44
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TLJoke } from '@thalorlabs/types';
|
|
2
|
+
export interface DadJokeClientConfig {
|
|
3
|
+
baseURL?: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* HTTP adapter for icanhazdadjoke.com.
|
|
8
|
+
*
|
|
9
|
+
* Calls the external API and normalises the raw response to TLJoke.
|
|
10
|
+
* No DB, no cache, no logging — pure HTTP adapter.
|
|
11
|
+
*
|
|
12
|
+
* When @thalorlabs/api is available, this should extend BaseHttpClient
|
|
13
|
+
* instead of managing its own axios instance.
|
|
14
|
+
*/
|
|
15
|
+
export declare class DadJokeClient {
|
|
16
|
+
readonly serviceName = "dadjoke";
|
|
17
|
+
private readonly axiosInstance;
|
|
18
|
+
constructor(config?: DadJokeClientConfig);
|
|
19
|
+
getJoke(): Promise<TLJoke>;
|
|
20
|
+
private normalise;
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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.DadJokeClient = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const types_1 = require("@thalorlabs/types");
|
|
10
|
+
/**
|
|
11
|
+
* HTTP adapter for icanhazdadjoke.com.
|
|
12
|
+
*
|
|
13
|
+
* Calls the external API and normalises the raw response to TLJoke.
|
|
14
|
+
* No DB, no cache, no logging — pure HTTP adapter.
|
|
15
|
+
*
|
|
16
|
+
* When @thalorlabs/api is available, this should extend BaseHttpClient
|
|
17
|
+
* instead of managing its own axios instance.
|
|
18
|
+
*/
|
|
19
|
+
class DadJokeClient {
|
|
20
|
+
constructor(config = {}) {
|
|
21
|
+
this.serviceName = 'dadjoke';
|
|
22
|
+
this.axiosInstance = axios_1.default.create({
|
|
23
|
+
baseURL: config.baseURL ?? 'https://icanhazdadjoke.com',
|
|
24
|
+
timeout: config.timeout ?? 10000,
|
|
25
|
+
headers: {
|
|
26
|
+
Accept: 'application/json',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async getJoke() {
|
|
31
|
+
const { data } = await this.axiosInstance.get('/');
|
|
32
|
+
return this.normalise(data);
|
|
33
|
+
}
|
|
34
|
+
normalise(raw) {
|
|
35
|
+
return types_1.TLJoke.parse({
|
|
36
|
+
id: (0, crypto_1.createHash)('md5').update(raw.joke).digest('hex'),
|
|
37
|
+
type: types_1.EJokeType.SINGLE,
|
|
38
|
+
joke: raw.joke,
|
|
39
|
+
category: types_1.EJokeCategory.DAD,
|
|
40
|
+
safe: true,
|
|
41
|
+
provider: this.serviceName,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.DadJokeClient = DadJokeClient;
|
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.DAD_JOKE_CONFIG = exports.DadJokeClient = void 0;
|
|
4
|
+
var DadJokeClient_1 = require("./DadJokeClient");
|
|
5
|
+
Object.defineProperty(exports, "DadJokeClient", { enumerable: true, get: function () { return DadJokeClient_1.DadJokeClient; } });
|
|
6
|
+
exports.DAD_JOKE_CONFIG = {
|
|
7
|
+
cacheTtlMs: 1000 * 60 * 60 * 24, // 24h — jokes don't change
|
|
8
|
+
isBillable: false, // icanhazdadjoke 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', // TypeScript handles this
|
|
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/dadjoke",
|
|
3
|
+
"author": "ThalorLabs",
|
|
4
|
+
"private": false,
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Provider adapter for icanhazdadjoke.com — returns TLJoke",
|
|
7
|
+
"homepage": "https://github.com/ThalorLabs/dadjoke#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/ThalorLabs/dadjoke/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ThalorLabs/dadjoke.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,49 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
|
|
4
|
+
import { DadJokeRawResponse } from './DadJokeTypes';
|
|
5
|
+
|
|
6
|
+
export interface DadJokeClientConfig {
|
|
7
|
+
baseURL?: string;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* HTTP adapter for icanhazdadjoke.com.
|
|
13
|
+
*
|
|
14
|
+
* Calls the external API and normalises the raw response to TLJoke.
|
|
15
|
+
* No DB, no cache, no logging — pure HTTP adapter.
|
|
16
|
+
*
|
|
17
|
+
* When @thalorlabs/api is available, this should extend BaseHttpClient
|
|
18
|
+
* instead of managing its own axios instance.
|
|
19
|
+
*/
|
|
20
|
+
export class DadJokeClient {
|
|
21
|
+
public readonly serviceName = 'dadjoke';
|
|
22
|
+
private readonly axiosInstance: AxiosInstance;
|
|
23
|
+
|
|
24
|
+
constructor(config: DadJokeClientConfig = {}) {
|
|
25
|
+
this.axiosInstance = axios.create({
|
|
26
|
+
baseURL: config.baseURL ?? 'https://icanhazdadjoke.com',
|
|
27
|
+
timeout: config.timeout ?? 10000,
|
|
28
|
+
headers: {
|
|
29
|
+
Accept: 'application/json',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getJoke(): Promise<TLJoke> {
|
|
35
|
+
const { data } = await this.axiosInstance.get<DadJokeRawResponse>('/');
|
|
36
|
+
return this.normalise(data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private normalise(raw: DadJokeRawResponse): TLJoke {
|
|
40
|
+
return TLJoke.parse({
|
|
41
|
+
id: createHash('md5').update(raw.joke).digest('hex'),
|
|
42
|
+
type: EJokeType.SINGLE,
|
|
43
|
+
joke: raw.joke,
|
|
44
|
+
category: EJokeCategory.DAD,
|
|
45
|
+
safe: true,
|
|
46
|
+
provider: this.serviceName,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DadJokeClient } from '../src/DadJokeClient';
|
|
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('DadJokeClient', () => {
|
|
24
|
+
let client: DadJokeClient;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
client = new DadJokeClient();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('constructor', () => {
|
|
32
|
+
it('sets serviceName to dadjoke', () => {
|
|
33
|
+
expect(client.serviceName).toBe('dadjoke');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('creates axios instance with default config', () => {
|
|
37
|
+
expect(axios.create).toHaveBeenCalledWith({
|
|
38
|
+
baseURL: 'https://icanhazdadjoke.com',
|
|
39
|
+
timeout: 10000,
|
|
40
|
+
headers: { Accept: 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accepts custom baseURL and timeout', () => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
new DadJokeClient({ baseURL: 'http://localhost:3000', timeout: 5000 });
|
|
47
|
+
expect(axios.create).toHaveBeenCalledWith({
|
|
48
|
+
baseURL: 'http://localhost:3000',
|
|
49
|
+
timeout: 5000,
|
|
50
|
+
headers: { Accept: 'application/json' },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getJoke', () => {
|
|
56
|
+
it('returns a valid TLJoke with SINGLE type and DAD category', async () => {
|
|
57
|
+
const mockGet = getMockAxios();
|
|
58
|
+
mockGet.mockResolvedValueOnce({
|
|
59
|
+
data: {
|
|
60
|
+
id: 'abc123',
|
|
61
|
+
joke: 'Why did the scarecrow win an award? He was outstanding in his field.',
|
|
62
|
+
status: 200,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const joke = await client.getJoke();
|
|
67
|
+
|
|
68
|
+
expect(joke.type).toBe(EJokeType.SINGLE);
|
|
69
|
+
expect(joke.category).toBe(EJokeCategory.DAD);
|
|
70
|
+
expect(joke.joke).toBe(
|
|
71
|
+
'Why did the scarecrow win an award? He was outstanding in his field.'
|
|
72
|
+
);
|
|
73
|
+
expect(joke.safe).toBe(true);
|
|
74
|
+
expect(joke.provider).toBe('dadjoke');
|
|
75
|
+
expect(joke.id).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('generates a deterministic id from joke text', async () => {
|
|
79
|
+
const mockGet = getMockAxios();
|
|
80
|
+
const jokeText = 'Test joke';
|
|
81
|
+
mockGet.mockResolvedValue({
|
|
82
|
+
data: { id: 'raw-id', joke: jokeText, status: 200 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const joke1 = await client.getJoke();
|
|
86
|
+
const joke2 = await client.getJoke();
|
|
87
|
+
|
|
88
|
+
expect(joke1.id).toBe(joke2.id);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('calls GET / on the API', async () => {
|
|
92
|
+
const mockGet = getMockAxios();
|
|
93
|
+
mockGet.mockResolvedValueOnce({
|
|
94
|
+
data: { id: 'abc', joke: 'A joke', status: 200 },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await client.getJoke();
|
|
98
|
+
|
|
99
|
+
expect(mockGet).toHaveBeenCalledWith('/');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('propagates API errors', async () => {
|
|
103
|
+
const mockGet = getMockAxios();
|
|
104
|
+
mockGet.mockRejectedValueOnce(new Error('Network error'));
|
|
105
|
+
|
|
106
|
+
await expect(client.getJoke()).rejects.toThrow('Network error');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
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
|
+
}
|