@thalorlabs/swapiinfo 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,33 @@
1
+ # CLAUDE.md — @thalorlabs/swapiinfo
2
+
3
+ ## What this is
4
+
5
+ Provider adapter for swapi.info (Star Wars API alternative). Returns `TLStarWarsFact`.
6
+
7
+ ## External API
8
+
9
+ - swapi.info — free, no auth, no billing, no rate limits
10
+ - Returns planet data from the Star Wars universe
11
+ - Hosted on Vercel with CDN caching, ~50ms responses
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
+ - Normalises raw swapi.info planet response to TLStarWarsFact
26
+ - Generates deterministic MD5 hash ID from planet name
27
+ - Sets: type=PLANET, faction=UNKNOWN, isSafe=true
28
+
29
+ ## What this package does NOT do
30
+
31
+ - No database, no caching, no logging — orchestrator handles that
32
+ - Raw types never exported
33
+ - No provider selection logic
@@ -0,0 +1,23 @@
1
+ import { BaseHttpClient } from '@thalorlabs/api';
2
+ import { TLStarWarsFact } from '@thalorlabs/types';
3
+ export interface SwapiInfoClientConfig {
4
+ baseURL?: string;
5
+ timeout?: number;
6
+ }
7
+ /**
8
+ * HTTP adapter for swapi.info (Star Wars API).
9
+ *
10
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
11
+ * observability. Normalises the raw planet response to TLStarWarsFact.
12
+ * No DB, no cache, no logging — pure HTTP adapter.
13
+ */
14
+ export declare class SwapiInfoClient extends BaseHttpClient {
15
+ readonly serviceName = "swapiinfo";
16
+ private static readonly MAX_PLANET_ID;
17
+ private static readonly MAX_RETRIES_404;
18
+ constructor(config?: SwapiInfoClientConfig);
19
+ getFact(): Promise<TLStarWarsFact>;
20
+ private randomPlanetId;
21
+ private is404;
22
+ private normalise;
23
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SwapiInfoClient = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const api_1 = require("@thalorlabs/api");
6
+ const types_1 = require("@thalorlabs/types");
7
+ /**
8
+ * HTTP adapter for swapi.info (Star Wars API).
9
+ *
10
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
11
+ * observability. Normalises the raw planet response to TLStarWarsFact.
12
+ * No DB, no cache, no logging — pure HTTP adapter.
13
+ */
14
+ class SwapiInfoClient extends api_1.BaseHttpClient {
15
+ constructor(config = {}) {
16
+ super({
17
+ baseURL: config.baseURL ?? 'https://swapi.info/api',
18
+ serviceName: 'swapiinfo',
19
+ retries: 3,
20
+ timeout: config.timeout ?? 10000,
21
+ });
22
+ this.serviceName = 'swapiinfo';
23
+ }
24
+ async getFact() {
25
+ let lastError;
26
+ for (let attempt = 0; attempt < SwapiInfoClient.MAX_RETRIES_404; attempt++) {
27
+ const id = this.randomPlanetId();
28
+ try {
29
+ const { response } = await this.handleRequest({ method: 'GET', url: `/planets/${id}` }, { method: 'GET', url: `/planets/${id}`, requestId: (0, crypto_1.randomUUID)() });
30
+ return this.normalise(response.data);
31
+ }
32
+ catch (err) {
33
+ lastError = err;
34
+ if (this.is404(err))
35
+ continue;
36
+ throw err;
37
+ }
38
+ }
39
+ throw lastError;
40
+ }
41
+ randomPlanetId() {
42
+ return Math.floor(Math.random() * SwapiInfoClient.MAX_PLANET_ID) + 1;
43
+ }
44
+ is404(err) {
45
+ if (err && typeof err === 'object' && 'response' in err) {
46
+ const resp = err.response;
47
+ return resp?.status === 404;
48
+ }
49
+ return false;
50
+ }
51
+ normalise(raw) {
52
+ return types_1.TLStarWarsFact.parse({
53
+ id: (0, crypto_1.createHash)('md5').update(raw.name).digest('hex'),
54
+ type: types_1.EStarWarsFactType.PLANET,
55
+ content: `${raw.name} is a ${raw.climate} planet with ${raw.terrain} terrain and a population of ${raw.population}.`,
56
+ character: undefined,
57
+ faction: types_1.EStarWarsFaction.UNKNOWN,
58
+ film: undefined,
59
+ isSafe: true,
60
+ });
61
+ }
62
+ }
63
+ exports.SwapiInfoClient = SwapiInfoClient;
64
+ SwapiInfoClient.MAX_PLANET_ID = 60;
65
+ SwapiInfoClient.MAX_RETRIES_404 = 5;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Raw response from swapi.info planet endpoint.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface SwapiInfoPlanetRaw {
8
+ name: string;
9
+ rotation_period: string;
10
+ orbital_period: string;
11
+ diameter: string;
12
+ climate: string;
13
+ gravity: string;
14
+ terrain: string;
15
+ surface_water: string;
16
+ population: string;
17
+ residents: string[];
18
+ films: string[];
19
+ url: string;
20
+ }
21
+ /**
22
+ * Raw response from swapi.info film endpoint.
23
+ *
24
+ * Internal to this package only — never exported from index.ts,
25
+ * never added to @thalorlabs/types.
26
+ */
27
+ export interface SwapiInfoFilmRaw {
28
+ title: string;
29
+ episode_id: number;
30
+ opening_crawl: string;
31
+ director: string;
32
+ producer: string;
33
+ release_date: string;
34
+ url: string;
35
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ export { SwapiInfoClient, SwapiInfoClientConfig } from './SwapiInfoClient';
2
+ export declare const SWAPIINFO_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.SWAPIINFO_CONFIG = exports.SwapiInfoClient = void 0;
4
+ var SwapiInfoClient_1 = require("./SwapiInfoClient");
5
+ Object.defineProperty(exports, "SwapiInfoClient", { enumerable: true, get: function () { return SwapiInfoClient_1.SwapiInfoClient; } });
6
+ exports.SWAPIINFO_CONFIG = {
7
+ cacheTtlMs: 1000 * 60 * 60 * 24 * 7, // 7 days — Star Wars 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/swapiinfo",
3
+ "author": "ThalorLabs",
4
+ "private": false,
5
+ "version": "1.0.0",
6
+ "description": "Provider adapter for swapi.info — returns TLStarWarsFact",
7
+ "homepage": "https://github.com/ThalorLabs/swapiinfo#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ThalorLabs/swapiinfo/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ThalorLabs/swapiinfo.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,80 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+
3
+ import { BaseHttpClient } from '@thalorlabs/api';
4
+ import { EStarWarsFaction, EStarWarsFactType, TLStarWarsFact } from '@thalorlabs/types';
5
+
6
+ import { SwapiInfoPlanetRaw } from './SwapiInfoTypes';
7
+
8
+ export interface SwapiInfoClientConfig {
9
+ baseURL?: string;
10
+ timeout?: number;
11
+ }
12
+
13
+ /**
14
+ * HTTP adapter for swapi.info (Star Wars API).
15
+ *
16
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
17
+ * observability. Normalises the raw planet response to TLStarWarsFact.
18
+ * No DB, no cache, no logging — pure HTTP adapter.
19
+ */
20
+ export class SwapiInfoClient extends BaseHttpClient {
21
+ public readonly serviceName = 'swapiinfo';
22
+
23
+ private static readonly MAX_PLANET_ID = 60;
24
+ private static readonly MAX_RETRIES_404 = 5;
25
+
26
+ constructor(config: SwapiInfoClientConfig = {}) {
27
+ super({
28
+ baseURL: config.baseURL ?? 'https://swapi.info/api',
29
+ serviceName: 'swapiinfo',
30
+ retries: 3,
31
+ timeout: config.timeout ?? 10000,
32
+ });
33
+ }
34
+
35
+ async getFact(): Promise<TLStarWarsFact> {
36
+ let lastError: unknown;
37
+
38
+ for (let attempt = 0; attempt < SwapiInfoClient.MAX_RETRIES_404; attempt++) {
39
+ const id = this.randomPlanetId();
40
+
41
+ try {
42
+ const { response } = await this.handleRequest<SwapiInfoPlanetRaw>(
43
+ { method: 'GET', url: `/planets/${id}` },
44
+ { method: 'GET', url: `/planets/${id}`, requestId: randomUUID() }
45
+ );
46
+ return this.normalise(response.data);
47
+ } catch (err: unknown) {
48
+ lastError = err;
49
+ if (this.is404(err)) continue;
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ throw lastError;
55
+ }
56
+
57
+ private randomPlanetId(): number {
58
+ return Math.floor(Math.random() * SwapiInfoClient.MAX_PLANET_ID) + 1;
59
+ }
60
+
61
+ private is404(err: unknown): boolean {
62
+ if (err && typeof err === 'object' && 'response' in err) {
63
+ const resp = (err as { response?: { status?: number } }).response;
64
+ return resp?.status === 404;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ private normalise(raw: SwapiInfoPlanetRaw): TLStarWarsFact {
70
+ return TLStarWarsFact.parse({
71
+ id: createHash('md5').update(raw.name).digest('hex'),
72
+ type: EStarWarsFactType.PLANET,
73
+ content: `${raw.name} is a ${raw.climate} planet with ${raw.terrain} terrain and a population of ${raw.population}.`,
74
+ character: undefined,
75
+ faction: EStarWarsFaction.UNKNOWN,
76
+ film: undefined,
77
+ isSafe: true,
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Raw response from swapi.info planet endpoint.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface SwapiInfoPlanetRaw {
8
+ name: string;
9
+ rotation_period: string;
10
+ orbital_period: string;
11
+ diameter: string;
12
+ climate: string;
13
+ gravity: string;
14
+ terrain: string;
15
+ surface_water: string;
16
+ population: string;
17
+ residents: string[];
18
+ films: string[];
19
+ url: string;
20
+ }
21
+
22
+ /**
23
+ * Raw response from swapi.info film endpoint.
24
+ *
25
+ * Internal to this package only — never exported from index.ts,
26
+ * never added to @thalorlabs/types.
27
+ */
28
+ export interface SwapiInfoFilmRaw {
29
+ title: string;
30
+ episode_id: number;
31
+ opening_crawl: string;
32
+ director: string;
33
+ producer: string;
34
+ release_date: string;
35
+ url: string;
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { SwapiInfoClient, SwapiInfoClientConfig } from './SwapiInfoClient';
2
+
3
+ export const SWAPIINFO_CONFIG = {
4
+ cacheTtlMs: 1000 * 60 * 60 * 24 * 7, // 7 days — Star Wars data never changes
5
+ isBillable: false,
6
+ } as const;
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SwapiInfoClient } from '../src/SwapiInfoClient';
3
+ import { EStarWarsFactType, EStarWarsFaction } 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_PLANET = {
13
+ name: 'Tatooine',
14
+ rotation_period: '23',
15
+ orbital_period: '304',
16
+ diameter: '10465',
17
+ climate: 'arid',
18
+ gravity: '1 standard',
19
+ terrain: 'desert',
20
+ surface_water: '1',
21
+ population: '200000',
22
+ residents: ['https://swapi.info/api/people/1'],
23
+ films: ['https://swapi.info/api/films/1'],
24
+ url: 'https://swapi.info/api/planets/1',
25
+ };
26
+
27
+ describe('SwapiInfoClient', () => {
28
+ let client: SwapiInfoClient;
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ client = new SwapiInfoClient();
33
+ });
34
+
35
+ describe('constructor', () => {
36
+ it('sets serviceName to swapiinfo', () => {
37
+ expect(client.serviceName).toBe('swapiinfo');
38
+ });
39
+ });
40
+
41
+ describe('getFact', () => {
42
+ it('returns a valid TLStarWarsFact with PLANET type', async () => {
43
+ mockHandleRequest.mockResolvedValueOnce({
44
+ response: { data: MOCK_PLANET },
45
+ durationMs: 50,
46
+ });
47
+
48
+ const fact = await client.getFact();
49
+
50
+ expect(fact.type).toBe(EStarWarsFactType.PLANET);
51
+ expect(fact.faction).toBe(EStarWarsFaction.UNKNOWN);
52
+ expect(fact.isSafe).toBe(true);
53
+ expect(fact.character).toBeUndefined();
54
+ expect(fact.film).toBeUndefined();
55
+ expect(fact.id).toBeDefined();
56
+ });
57
+
58
+ it('formats content string correctly', async () => {
59
+ mockHandleRequest.mockResolvedValueOnce({
60
+ response: { data: MOCK_PLANET },
61
+ durationMs: 50,
62
+ });
63
+
64
+ const fact = await client.getFact();
65
+
66
+ expect(fact.content).toBe(
67
+ 'Tatooine is a arid planet with desert terrain and a population of 200000.'
68
+ );
69
+ });
70
+
71
+ it('generates a deterministic id from planet name', async () => {
72
+ mockHandleRequest.mockResolvedValue({
73
+ response: { data: MOCK_PLANET },
74
+ durationMs: 10,
75
+ });
76
+
77
+ const fact1 = await client.getFact();
78
+ const fact2 = await client.getFact();
79
+
80
+ expect(fact1.id).toBe(fact2.id);
81
+ });
82
+
83
+ it('calls handleRequest with GET /planets/{id}', async () => {
84
+ mockHandleRequest.mockResolvedValueOnce({
85
+ response: { data: MOCK_PLANET },
86
+ durationMs: 10,
87
+ });
88
+
89
+ await client.getFact();
90
+
91
+ expect(mockHandleRequest).toHaveBeenCalledWith(
92
+ expect.objectContaining({ method: 'GET' }),
93
+ expect.objectContaining({ method: 'GET' })
94
+ );
95
+ const callUrl = mockHandleRequest.mock.calls[0][0].url;
96
+ expect(callUrl).toMatch(/^\/planets\/\d+$/);
97
+ });
98
+
99
+ it('retries on 404 with a different planet ID', async () => {
100
+ const err404 = Object.assign(new Error('Not Found'), {
101
+ response: { status: 404 },
102
+ });
103
+ mockHandleRequest
104
+ .mockRejectedValueOnce(err404)
105
+ .mockResolvedValueOnce({
106
+ response: { data: MOCK_PLANET },
107
+ durationMs: 10,
108
+ });
109
+
110
+ const fact = await client.getFact();
111
+
112
+ expect(mockHandleRequest).toHaveBeenCalledTimes(2);
113
+ expect(fact.type).toBe(EStarWarsFactType.PLANET);
114
+ });
115
+
116
+ it('propagates non-404 errors immediately', async () => {
117
+ mockHandleRequest.mockRejectedValueOnce(new Error('Network error'));
118
+
119
+ await expect(client.getFact()).rejects.toThrow('Network error');
120
+ expect(mockHandleRequest).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it('throws after exhausting 404 retries', async () => {
124
+ const err404 = Object.assign(new Error('Not Found'), {
125
+ response: { status: 404 },
126
+ });
127
+ mockHandleRequest.mockRejectedValue(err404);
128
+
129
+ await expect(client.getFact()).rejects.toThrow('Not Found');
130
+ expect(mockHandleRequest).toHaveBeenCalledTimes(5);
131
+ });
132
+ });
133
+ });
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
+ }