@thalorlabs/pokeapi 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/.prettierrc ADDED
@@ -0,0 +1 @@
1
+ "@thalorlabs/eslint-plugin-dev-config/prettier"
package/CLAUDE.md ADDED
@@ -0,0 +1,36 @@
1
+ # CLAUDE.md — @thalorlabs/pokeapi
2
+
3
+ ## What this is
4
+
5
+ Provider adapter for pokeapi.co (PokeAPI). Returns `TLPokemon`.
6
+
7
+ ## External API
8
+
9
+ - pokeapi.co — free, no auth, no billing, no rate limits
10
+ - Multi-call provider: /pokemon/{id} + /pokemon-species/{id}
11
+ - 1025 Pokemon in the National Dex
12
+
13
+ ## Build & publish
14
+
15
+ ```bash
16
+ npm run build # tsc → dist/
17
+ npm test # vitest
18
+ npm version patch # or minor/major
19
+ npm publish --access public
20
+ ```
21
+
22
+ ## What this package does
23
+
24
+ - HTTP adapter extending BaseHttpClient
25
+ - Two API calls per getPokemon() — pokemon data + species data
26
+ - Normalises combined response to TLPokemon
27
+ - Maps type names to EPokemonType enum (skips unknown types)
28
+ - Parses generation from roman numeral format
29
+ - Cleans flavor text (removes newlines/form feeds)
30
+ - Sets: isSafe=true
31
+
32
+ ## What this package does NOT do
33
+
34
+ - No database, no caching, no logging — orchestrator handles that
35
+ - Raw types never exported
36
+ - No provider selection logic
@@ -0,0 +1,20 @@
1
+ import { BaseHttpClient } from '@thalorlabs/api';
2
+ import { TLPokemon } from '@thalorlabs/types';
3
+ export interface PokeApiClientConfig {
4
+ baseURL?: string;
5
+ timeout?: number;
6
+ }
7
+ /**
8
+ * HTTP adapter for pokeapi.co.
9
+ *
10
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
11
+ * observability. Multi-call provider: fetches pokemon data + species data,
12
+ * then normalises the combined response to TLPokemon.
13
+ * No DB, no cache, no logging — pure HTTP adapter.
14
+ */
15
+ export declare class PokeApiClient extends BaseHttpClient {
16
+ readonly serviceName = "pokeapi";
17
+ constructor(config?: PokeApiClientConfig);
18
+ getPokemon(): Promise<TLPokemon>;
19
+ private normalise;
20
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PokeApiClient = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const api_1 = require("@thalorlabs/api");
6
+ const types_1 = require("@thalorlabs/types");
7
+ const ROMAN_NUMERAL_MAP = {
8
+ i: 1,
9
+ ii: 2,
10
+ iii: 3,
11
+ iv: 4,
12
+ v: 5,
13
+ vi: 6,
14
+ vii: 7,
15
+ viii: 8,
16
+ ix: 9,
17
+ };
18
+ /**
19
+ * HTTP adapter for pokeapi.co.
20
+ *
21
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
22
+ * observability. Multi-call provider: fetches pokemon data + species data,
23
+ * then normalises the combined response to TLPokemon.
24
+ * No DB, no cache, no logging — pure HTTP adapter.
25
+ */
26
+ class PokeApiClient extends api_1.BaseHttpClient {
27
+ constructor(config = {}) {
28
+ super({
29
+ baseURL: config.baseURL ?? 'https://pokeapi.co/api/v2',
30
+ serviceName: 'pokeapi',
31
+ retries: 3,
32
+ timeout: config.timeout ?? 10000,
33
+ });
34
+ this.serviceName = 'pokeapi';
35
+ }
36
+ async getPokemon() {
37
+ const pokemonId = Math.floor(Math.random() * 1025) + 1;
38
+ const { response: pokemonResponse } = await this.handleRequest({ method: 'GET', url: `/pokemon/${pokemonId}` }, { method: 'GET', url: `/pokemon/${pokemonId}`, requestId: (0, crypto_1.randomUUID)() });
39
+ const { response: speciesResponse } = await this.handleRequest({ method: 'GET', url: `/pokemon-species/${pokemonId}` }, {
40
+ method: 'GET',
41
+ url: `/pokemon-species/${pokemonId}`,
42
+ requestId: (0, crypto_1.randomUUID)(),
43
+ });
44
+ return this.normalise(pokemonResponse.data, speciesResponse.data);
45
+ }
46
+ normalise(pokemon, species) {
47
+ const name = pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1);
48
+ const types = pokemon.types
49
+ .map((t) => t.type.name.toUpperCase())
50
+ .filter((t) => Object.values(types_1.EPokemonType).includes(t));
51
+ const englishEntry = species.flavor_text_entries.find((e) => e.language.name === 'en');
52
+ const description = englishEntry
53
+ ? englishEntry.flavor_text.replace(/[\n\f\r]/g, ' ')
54
+ : '';
55
+ const generationRoman = species.generation.name.split('-')[1];
56
+ const generation = ROMAN_NUMERAL_MAP[generationRoman] ?? 0;
57
+ const imageUrl = pokemon.sprites.other['official-artwork'].front_default;
58
+ return types_1.TLPokemon.parse({
59
+ id: (0, crypto_1.createHash)('md5').update(pokemon.name).digest('hex'),
60
+ name,
61
+ types,
62
+ description,
63
+ imageUrl,
64
+ generation,
65
+ isSafe: true,
66
+ });
67
+ }
68
+ }
69
+ exports.PokeApiClient = PokeApiClient;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Raw response types from pokeapi.co API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface PokeApiPokemonRaw {
8
+ id: number;
9
+ name: string;
10
+ types: Array<{
11
+ slot: number;
12
+ type: {
13
+ name: string;
14
+ url: string;
15
+ };
16
+ }>;
17
+ sprites: {
18
+ front_default: string;
19
+ other: {
20
+ 'official-artwork': {
21
+ front_default: string;
22
+ };
23
+ };
24
+ };
25
+ species: {
26
+ name: string;
27
+ url: string;
28
+ };
29
+ }
30
+ export interface PokeApiSpeciesRaw {
31
+ id: number;
32
+ name: string;
33
+ generation: {
34
+ name: string;
35
+ url: string;
36
+ };
37
+ flavor_text_entries: Array<{
38
+ flavor_text: string;
39
+ language: {
40
+ name: string;
41
+ };
42
+ version: {
43
+ name: string;
44
+ };
45
+ }>;
46
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * Raw response types from pokeapi.co API.
4
+ *
5
+ * Internal to this package only — never exported from index.ts,
6
+ * never added to @thalorlabs/types.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ export { PokeApiClient, PokeApiClientConfig } from './PokeApiClient';
2
+ export declare const POKEAPI_CONFIG: {
3
+ readonly cacheTtlMs: number;
4
+ readonly isBillable: false;
5
+ };
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POKEAPI_CONFIG = exports.PokeApiClient = void 0;
4
+ var PokeApiClient_1 = require("./PokeApiClient");
5
+ Object.defineProperty(exports, "PokeApiClient", { enumerable: true, get: function () { return PokeApiClient_1.PokeApiClient; } });
6
+ exports.POKEAPI_CONFIG = {
7
+ cacheTtlMs: 1000 * 60 * 60 * 24 * 7, // 7 days — Pokemon data never changes
8
+ isBillable: false,
9
+ };
@@ -0,0 +1,2 @@
1
+ import { backend } from '@thalorlabs/eslint-plugin-dev-config';
2
+ export default backend;
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@thalorlabs/pokeapi",
3
+ "author": "ThalorLabs",
4
+ "private": false,
5
+ "version": "1.0.0",
6
+ "description": "Provider adapter for pokeapi.co — returns TLPokemon",
7
+ "homepage": "https://github.com/ThalorLabs/pokeapi#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ThalorLabs/pokeapi/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ThalorLabs/pokeapi.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
+ "@thalorlabs/eslint-plugin-dev-config": "^2.1.1",
32
+ "@types/node": "^25.5.2",
33
+ "prettier": "^3.8.1",
34
+ "rimraf": "^5.0.0",
35
+ "typescript": "^5.0.0",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@thalorlabs/api": "^1.0.0",
40
+ "@thalorlabs/types": "^1.9.0"
41
+ }
42
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+
3
+ import { BaseHttpClient } from '@thalorlabs/api';
4
+ import { EPokemonType, TLPokemon } from '@thalorlabs/types';
5
+
6
+ import { PokeApiPokemonRaw, PokeApiSpeciesRaw } from './PokeApiTypes';
7
+
8
+ export interface PokeApiClientConfig {
9
+ baseURL?: string;
10
+ timeout?: number;
11
+ }
12
+
13
+ const ROMAN_NUMERAL_MAP: Record<string, number> = {
14
+ i: 1,
15
+ ii: 2,
16
+ iii: 3,
17
+ iv: 4,
18
+ v: 5,
19
+ vi: 6,
20
+ vii: 7,
21
+ viii: 8,
22
+ ix: 9,
23
+ };
24
+
25
+ /**
26
+ * HTTP adapter for pokeapi.co.
27
+ *
28
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
29
+ * observability. Multi-call provider: fetches pokemon data + species data,
30
+ * then normalises the combined response to TLPokemon.
31
+ * No DB, no cache, no logging — pure HTTP adapter.
32
+ */
33
+ export class PokeApiClient extends BaseHttpClient {
34
+ public readonly serviceName = 'pokeapi';
35
+
36
+ constructor(config: PokeApiClientConfig = {}) {
37
+ super({
38
+ baseURL: config.baseURL ?? 'https://pokeapi.co/api/v2',
39
+ serviceName: 'pokeapi',
40
+ retries: 3,
41
+ timeout: config.timeout ?? 10000,
42
+ });
43
+ }
44
+
45
+ async getPokemon(): Promise<TLPokemon> {
46
+ const pokemonId = Math.floor(Math.random() * 1025) + 1;
47
+
48
+ const { response: pokemonResponse } =
49
+ await this.handleRequest<PokeApiPokemonRaw>(
50
+ { method: 'GET', url: `/pokemon/${pokemonId}` },
51
+ { method: 'GET', url: `/pokemon/${pokemonId}`, requestId: randomUUID() }
52
+ );
53
+
54
+ const { response: speciesResponse } =
55
+ await this.handleRequest<PokeApiSpeciesRaw>(
56
+ { method: 'GET', url: `/pokemon-species/${pokemonId}` },
57
+ {
58
+ method: 'GET',
59
+ url: `/pokemon-species/${pokemonId}`,
60
+ requestId: randomUUID(),
61
+ }
62
+ );
63
+
64
+ return this.normalise(pokemonResponse.data, speciesResponse.data);
65
+ }
66
+
67
+ private normalise(
68
+ pokemon: PokeApiPokemonRaw,
69
+ species: PokeApiSpeciesRaw
70
+ ): TLPokemon {
71
+ const name =
72
+ pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1);
73
+
74
+ const types = pokemon.types
75
+ .map((t) => t.type.name.toUpperCase())
76
+ .filter((t): t is EPokemonType =>
77
+ Object.values(EPokemonType).includes(t as EPokemonType)
78
+ );
79
+
80
+ const englishEntry = species.flavor_text_entries.find(
81
+ (e) => e.language.name === 'en'
82
+ );
83
+ const description = englishEntry
84
+ ? englishEntry.flavor_text.replace(/[\n\f\r]/g, ' ')
85
+ : '';
86
+
87
+ const generationRoman = species.generation.name.split('-')[1];
88
+ const generation = ROMAN_NUMERAL_MAP[generationRoman] ?? 0;
89
+
90
+ const imageUrl =
91
+ pokemon.sprites.other['official-artwork'].front_default;
92
+
93
+ return TLPokemon.parse({
94
+ id: createHash('md5').update(pokemon.name).digest('hex'),
95
+ name,
96
+ types,
97
+ description,
98
+ imageUrl,
99
+ generation,
100
+ isSafe: true,
101
+ });
102
+ }
103
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Raw response types from pokeapi.co API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+
8
+ export interface PokeApiPokemonRaw {
9
+ id: number;
10
+ name: string;
11
+ types: Array<{
12
+ slot: number;
13
+ type: {
14
+ name: string;
15
+ url: string;
16
+ };
17
+ }>;
18
+ sprites: {
19
+ front_default: string;
20
+ other: {
21
+ 'official-artwork': {
22
+ front_default: string;
23
+ };
24
+ };
25
+ };
26
+ species: {
27
+ name: string;
28
+ url: string;
29
+ };
30
+ }
31
+
32
+ export interface PokeApiSpeciesRaw {
33
+ id: number;
34
+ name: string;
35
+ generation: {
36
+ name: string; // e.g. "generation-i"
37
+ url: string;
38
+ };
39
+ flavor_text_entries: Array<{
40
+ flavor_text: string;
41
+ language: {
42
+ name: string;
43
+ };
44
+ version: {
45
+ name: string;
46
+ };
47
+ }>;
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { PokeApiClient, PokeApiClientConfig } from './PokeApiClient';
2
+
3
+ export const POKEAPI_CONFIG = {
4
+ cacheTtlMs: 1000 * 60 * 60 * 24 * 7, // 7 days — Pokemon data never changes
5
+ isBillable: false,
6
+ } as const;
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { PokeApiClient } from '../src/PokeApiClient';
3
+ import { EPokemonType } from '@thalorlabs/types';
4
+ import { BaseHttpClient } from '@thalorlabs/api';
5
+
6
+ // Mock handleRequest on the prototype — no axios import needed in tests
7
+ const mockHandleRequest = vi.fn();
8
+ vi.spyOn(BaseHttpClient.prototype as any, 'handleRequest').mockImplementation(
9
+ mockHandleRequest
10
+ );
11
+
12
+ const MOCK_POKEMON_RAW = {
13
+ id: 25,
14
+ name: 'pikachu',
15
+ types: [{ slot: 1, type: { name: 'electric', url: '' } }],
16
+ sprites: {
17
+ front_default: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png',
18
+ other: { 'official-artwork': { front_default: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png' } },
19
+ },
20
+ species: { name: 'pikachu', url: '' },
21
+ };
22
+
23
+ const MOCK_SPECIES_RAW = {
24
+ id: 25,
25
+ name: 'pikachu',
26
+ generation: { name: 'generation-i', url: '' },
27
+ flavor_text_entries: [
28
+ {
29
+ flavor_text: 'An electric\nPokemon.',
30
+ language: { name: 'en' },
31
+ version: { name: 'red' },
32
+ },
33
+ ],
34
+ };
35
+
36
+ function mockBothCalls() {
37
+ mockHandleRequest
38
+ .mockResolvedValueOnce({
39
+ response: { data: MOCK_POKEMON_RAW },
40
+ durationMs: 50,
41
+ })
42
+ .mockResolvedValueOnce({
43
+ response: { data: MOCK_SPECIES_RAW },
44
+ durationMs: 50,
45
+ });
46
+ }
47
+
48
+ describe('PokeApiClient', () => {
49
+ let client: PokeApiClient;
50
+
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ client = new PokeApiClient();
54
+ });
55
+
56
+ describe('constructor', () => {
57
+ it('sets serviceName to pokeapi', () => {
58
+ expect(client.serviceName).toBe('pokeapi');
59
+ });
60
+ });
61
+
62
+ describe('getPokemon', () => {
63
+ it('returns a valid TLPokemon', async () => {
64
+ mockBothCalls();
65
+
66
+ const pokemon = await client.getPokemon();
67
+
68
+ expect(pokemon.name).toBe('Pikachu');
69
+ expect(pokemon.isSafe).toBe(true);
70
+ expect(pokemon.id).toBeDefined();
71
+ expect(pokemon.imageUrl).toBe('https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png');
72
+ });
73
+
74
+ it('maps known EPokemonType values and skips unknown types', async () => {
75
+ const pokemonWithMixedTypes = {
76
+ ...MOCK_POKEMON_RAW,
77
+ types: [
78
+ { slot: 1, type: { name: 'electric', url: '' } },
79
+ { slot: 2, type: { name: 'fighting', url: '' } },
80
+ ],
81
+ };
82
+
83
+ mockHandleRequest
84
+ .mockResolvedValueOnce({
85
+ response: { data: pokemonWithMixedTypes },
86
+ durationMs: 50,
87
+ })
88
+ .mockResolvedValueOnce({
89
+ response: { data: MOCK_SPECIES_RAW },
90
+ durationMs: 50,
91
+ });
92
+
93
+ const pokemon = await client.getPokemon();
94
+
95
+ expect(pokemon.types).toContain(EPokemonType.ELECTRIC);
96
+ expect(pokemon.types).not.toContain('FIGHTING');
97
+ expect(pokemon.types).toHaveLength(1);
98
+ });
99
+
100
+ it('cleans flavor text by replacing newlines and form feeds', async () => {
101
+ const speciesWithDirtyText = {
102
+ ...MOCK_SPECIES_RAW,
103
+ flavor_text_entries: [
104
+ {
105
+ flavor_text: 'A cute\felectric\npokemon\rthat sparks.',
106
+ language: { name: 'en' },
107
+ version: { name: 'red' },
108
+ },
109
+ ],
110
+ };
111
+
112
+ mockHandleRequest
113
+ .mockResolvedValueOnce({
114
+ response: { data: MOCK_POKEMON_RAW },
115
+ durationMs: 50,
116
+ })
117
+ .mockResolvedValueOnce({
118
+ response: { data: speciesWithDirtyText },
119
+ durationMs: 50,
120
+ });
121
+
122
+ const pokemon = await client.getPokemon();
123
+
124
+ expect(pokemon.description).toBe(
125
+ 'A cute electric pokemon that sparks.'
126
+ );
127
+ expect(pokemon.description).not.toMatch(/[\n\f\r]/);
128
+ });
129
+
130
+ it('parses generation number from roman numerals', async () => {
131
+ mockBothCalls();
132
+
133
+ const pokemon = await client.getPokemon();
134
+
135
+ expect(pokemon.generation).toBe(1);
136
+ });
137
+
138
+ it('parses higher generation roman numerals correctly', async () => {
139
+ const speciesGen4 = {
140
+ ...MOCK_SPECIES_RAW,
141
+ generation: { name: 'generation-iv', url: '' },
142
+ };
143
+
144
+ mockHandleRequest
145
+ .mockResolvedValueOnce({
146
+ response: { data: MOCK_POKEMON_RAW },
147
+ durationMs: 50,
148
+ })
149
+ .mockResolvedValueOnce({
150
+ response: { data: speciesGen4 },
151
+ durationMs: 50,
152
+ });
153
+
154
+ const pokemon = await client.getPokemon();
155
+
156
+ expect(pokemon.generation).toBe(4);
157
+ });
158
+
159
+ it('capitalizes the pokemon name', async () => {
160
+ mockBothCalls();
161
+
162
+ const pokemon = await client.getPokemon();
163
+
164
+ expect(pokemon.name).toBe('Pikachu');
165
+ });
166
+
167
+ it('generates a deterministic id from pokemon name', async () => {
168
+ mockBothCalls();
169
+ const pokemon1 = await client.getPokemon();
170
+
171
+ mockBothCalls();
172
+ const pokemon2 = await client.getPokemon();
173
+
174
+ expect(pokemon1.id).toBe(pokemon2.id);
175
+ });
176
+
177
+ it('makes two handleRequest calls (pokemon + species)', async () => {
178
+ mockBothCalls();
179
+
180
+ await client.getPokemon();
181
+
182
+ expect(mockHandleRequest).toHaveBeenCalledTimes(2);
183
+ expect(mockHandleRequest).toHaveBeenCalledWith(
184
+ expect.objectContaining({ method: 'GET' }),
185
+ expect.objectContaining({ method: 'GET' })
186
+ );
187
+ });
188
+
189
+ it('propagates errors from handleRequest', async () => {
190
+ mockHandleRequest.mockRejectedValueOnce(new Error('Network error'));
191
+
192
+ await expect(client.getPokemon()).rejects.toThrow('Network error');
193
+ });
194
+ });
195
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@thalorlabs/eslint-plugin-dev-config/tsconfig/backend.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist"
5
+ },
6
+ "include": ["src"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }