@thalorlabs/swapi 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 ADDED
File without changes
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/swapi
2
+
3
+ ## What this is
4
+
5
+ Provider adapter for swapi.dev (Star Wars API). Returns `TLStarWarsFact`.
6
+
7
+ ## External API
8
+
9
+ - swapi.dev — free, no auth, no billing
10
+ - Returns character data from the Star Wars universe
11
+ - No random endpoint — uses random ID selection
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.dev response to TLStarWarsFact
26
+ - Generates deterministic MD5 hash ID from character name
27
+ - Sets: type=CHARACTER, 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,21 @@
1
+ import { BaseHttpClient } from '@thalorlabs/api';
2
+ import { TLStarWarsFact } from '@thalorlabs/types';
3
+ export interface SwapiClientConfig {
4
+ baseURL?: string;
5
+ timeout?: number;
6
+ }
7
+ /**
8
+ * HTTP adapter for swapi.dev (Star Wars API).
9
+ *
10
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
11
+ * observability. Normalises the raw response to TLStarWarsFact.
12
+ * No DB, no cache, no logging — pure HTTP adapter.
13
+ */
14
+ export declare class SwapiClient extends BaseHttpClient {
15
+ readonly serviceName = "swapi";
16
+ private static readonly MAX_PERSON_ID;
17
+ private static readonly MAX_RETRIES_FOR_GAPS;
18
+ constructor(config?: SwapiClientConfig);
19
+ getFact(): Promise<TLStarWarsFact>;
20
+ private normalise;
21
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SwapiClient = 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.dev (Star Wars API).
9
+ *
10
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
11
+ * observability. Normalises the raw response to TLStarWarsFact.
12
+ * No DB, no cache, no logging — pure HTTP adapter.
13
+ */
14
+ class SwapiClient extends api_1.BaseHttpClient {
15
+ constructor(config = {}) {
16
+ super({
17
+ baseURL: config.baseURL ?? 'https://swapi.dev/api',
18
+ serviceName: 'swapi',
19
+ retries: 3,
20
+ timeout: config.timeout ?? 10000,
21
+ });
22
+ this.serviceName = 'swapi';
23
+ }
24
+ async getFact() {
25
+ let lastError;
26
+ for (let attempt = 0; attempt < SwapiClient.MAX_RETRIES_FOR_GAPS; attempt++) {
27
+ const id = Math.floor(Math.random() * SwapiClient.MAX_PERSON_ID) + 1;
28
+ try {
29
+ const { response } = await this.handleRequest({ method: 'GET', url: `/people/${id}/` }, { method: 'GET', url: `/people/${id}/`, requestId: (0, crypto_1.randomUUID)() });
30
+ return this.normalise(response.data);
31
+ }
32
+ catch (error) {
33
+ lastError = error;
34
+ // If it's a 404 (gap in IDs), retry with a different ID
35
+ if (error?.response?.status === 404 || error?.status === 404) {
36
+ continue;
37
+ }
38
+ // For non-404 errors, throw immediately
39
+ throw error;
40
+ }
41
+ }
42
+ throw lastError ?? new Error('Failed to fetch Star Wars character after maximum retries');
43
+ }
44
+ normalise(raw) {
45
+ const content = `${raw.name} is a ${raw.gender} character born in ${raw.birth_year}, standing ${raw.height}cm tall.`;
46
+ return types_1.TLStarWarsFact.parse({
47
+ id: (0, crypto_1.createHash)('md5').update(raw.name).digest('hex'),
48
+ type: types_1.EStarWarsFactType.CHARACTER,
49
+ content,
50
+ character: raw.name,
51
+ faction: types_1.EStarWarsFaction.UNKNOWN,
52
+ film: undefined,
53
+ isSafe: true,
54
+ });
55
+ }
56
+ }
57
+ exports.SwapiClient = SwapiClient;
58
+ SwapiClient.MAX_PERSON_ID = 82;
59
+ SwapiClient.MAX_RETRIES_FOR_GAPS = 5;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Raw response types from swapi.dev API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface SwapiPersonRaw {
8
+ name: string;
9
+ height: string;
10
+ mass: string;
11
+ hair_color: string;
12
+ skin_color: string;
13
+ eye_color: string;
14
+ birth_year: string;
15
+ gender: string;
16
+ homeworld: string;
17
+ films: string[];
18
+ species: string[];
19
+ vehicles: string[];
20
+ starships: string[];
21
+ created: string;
22
+ edited: string;
23
+ url: string;
24
+ }
25
+ export interface SwapiPlanetRaw {
26
+ name: string;
27
+ rotation_period: string;
28
+ orbital_period: string;
29
+ diameter: string;
30
+ climate: string;
31
+ gravity: string;
32
+ terrain: string;
33
+ surface_water: string;
34
+ population: string;
35
+ residents: string[];
36
+ films: string[];
37
+ created: string;
38
+ edited: string;
39
+ url: string;
40
+ }
41
+ export interface SwapiFilmRaw {
42
+ title: string;
43
+ episode_id: number;
44
+ opening_crawl: string;
45
+ director: string;
46
+ producer: string;
47
+ release_date: string;
48
+ characters: string[];
49
+ planets: string[];
50
+ starships: string[];
51
+ vehicles: string[];
52
+ species: string[];
53
+ created: string;
54
+ edited: string;
55
+ url: string;
56
+ }
57
+ export interface SwapiListResponse<T> {
58
+ count: number;
59
+ next: string | null;
60
+ previous: string | null;
61
+ results: T[];
62
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * Raw response types from swapi.dev 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 { SwapiClient, SwapiClientConfig } from './SwapiClient';
2
+ export declare const SWAPI_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.SWAPI_CONFIG = exports.SwapiClient = void 0;
4
+ var SwapiClient_1 = require("./SwapiClient");
5
+ Object.defineProperty(exports, "SwapiClient", { enumerable: true, get: function () { return SwapiClient_1.SwapiClient; } });
6
+ exports.SWAPI_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/swapi",
3
+ "author": "ThalorLabs",
4
+ "private": false,
5
+ "version": "1.0.0",
6
+ "description": "Provider adapter for swapi.dev — returns TLStarWarsFact",
7
+ "homepage": "https://github.com/ThalorLabs/swapi#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ThalorLabs/swapi/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ThalorLabs/swapi.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,76 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+
3
+ import { BaseHttpClient } from '@thalorlabs/api';
4
+ import { EStarWarsFaction, EStarWarsFactType, TLStarWarsFact } from '@thalorlabs/types';
5
+
6
+ import { SwapiPersonRaw } from './SwapiTypes';
7
+
8
+ export interface SwapiClientConfig {
9
+ baseURL?: string;
10
+ timeout?: number;
11
+ }
12
+
13
+ /**
14
+ * HTTP adapter for swapi.dev (Star Wars API).
15
+ *
16
+ * Extends BaseHttpClient for retry logic, exponential backoff, and
17
+ * observability. Normalises the raw response to TLStarWarsFact.
18
+ * No DB, no cache, no logging — pure HTTP adapter.
19
+ */
20
+ export class SwapiClient extends BaseHttpClient {
21
+ public readonly serviceName = 'swapi';
22
+
23
+ private static readonly MAX_PERSON_ID = 82;
24
+ private static readonly MAX_RETRIES_FOR_GAPS = 5;
25
+
26
+ constructor(config: SwapiClientConfig = {}) {
27
+ super({
28
+ baseURL: config.baseURL ?? 'https://swapi.dev/api',
29
+ serviceName: 'swapi',
30
+ retries: 3,
31
+ timeout: config.timeout ?? 10000,
32
+ });
33
+ }
34
+
35
+ async getFact(): Promise<TLStarWarsFact> {
36
+ let lastError: Error | undefined;
37
+
38
+ for (let attempt = 0; attempt < SwapiClient.MAX_RETRIES_FOR_GAPS; attempt++) {
39
+ const id = Math.floor(Math.random() * SwapiClient.MAX_PERSON_ID) + 1;
40
+
41
+ try {
42
+ const { response } = await this.handleRequest<SwapiPersonRaw>(
43
+ { method: 'GET', url: `/people/${id}/` },
44
+ { method: 'GET', url: `/people/${id}/`, requestId: randomUUID() }
45
+ );
46
+ return this.normalise(response.data);
47
+ } catch (error: any) {
48
+ lastError = error;
49
+
50
+ // If it's a 404 (gap in IDs), retry with a different ID
51
+ if (error?.response?.status === 404 || error?.status === 404) {
52
+ continue;
53
+ }
54
+
55
+ // For non-404 errors, throw immediately
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ throw lastError ?? new Error('Failed to fetch Star Wars character after maximum retries');
61
+ }
62
+
63
+ private normalise(raw: SwapiPersonRaw): TLStarWarsFact {
64
+ const content = `${raw.name} is a ${raw.gender} character born in ${raw.birth_year}, standing ${raw.height}cm tall.`;
65
+
66
+ return TLStarWarsFact.parse({
67
+ id: createHash('md5').update(raw.name).digest('hex'),
68
+ type: EStarWarsFactType.CHARACTER,
69
+ content,
70
+ character: raw.name,
71
+ faction: EStarWarsFaction.UNKNOWN,
72
+ film: undefined,
73
+ isSafe: true,
74
+ });
75
+ }
76
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Raw response types from swapi.dev API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+
8
+ export interface SwapiPersonRaw {
9
+ name: string;
10
+ height: string;
11
+ mass: string;
12
+ hair_color: string;
13
+ skin_color: string;
14
+ eye_color: string;
15
+ birth_year: string;
16
+ gender: string;
17
+ homeworld: string;
18
+ films: string[];
19
+ species: string[];
20
+ vehicles: string[];
21
+ starships: string[];
22
+ created: string;
23
+ edited: string;
24
+ url: string;
25
+ }
26
+
27
+ export interface SwapiPlanetRaw {
28
+ name: string;
29
+ rotation_period: string;
30
+ orbital_period: string;
31
+ diameter: string;
32
+ climate: string;
33
+ gravity: string;
34
+ terrain: string;
35
+ surface_water: string;
36
+ population: string;
37
+ residents: string[];
38
+ films: string[];
39
+ created: string;
40
+ edited: string;
41
+ url: string;
42
+ }
43
+
44
+ export interface SwapiFilmRaw {
45
+ title: string;
46
+ episode_id: number;
47
+ opening_crawl: string;
48
+ director: string;
49
+ producer: string;
50
+ release_date: string;
51
+ characters: string[];
52
+ planets: string[];
53
+ starships: string[];
54
+ vehicles: string[];
55
+ species: string[];
56
+ created: string;
57
+ edited: string;
58
+ url: string;
59
+ }
60
+
61
+ export interface SwapiListResponse<T> {
62
+ count: number;
63
+ next: string | null;
64
+ previous: string | null;
65
+ results: T[];
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { SwapiClient, SwapiClientConfig } from './SwapiClient';
2
+
3
+ export const SWAPI_CONFIG = {
4
+ cacheTtlMs: 1000 * 60 * 60 * 24 * 7, // 7 days — Star Wars data never changes
5
+ isBillable: false,
6
+ } as const;
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SwapiClient } from '../src/SwapiClient';
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
+ describe('SwapiClient', () => {
13
+ let client: SwapiClient;
14
+
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ client = new SwapiClient();
18
+ });
19
+
20
+ describe('constructor', () => {
21
+ it('sets serviceName to swapi', () => {
22
+ expect(client.serviceName).toBe('swapi');
23
+ });
24
+ });
25
+
26
+ describe('getFact', () => {
27
+ it('returns a valid TLStarWarsFact with CHARACTER type', async () => {
28
+ mockHandleRequest.mockResolvedValueOnce({
29
+ response: {
30
+ data: {
31
+ name: 'Luke Skywalker',
32
+ height: '172',
33
+ mass: '77',
34
+ hair_color: 'blond',
35
+ skin_color: 'fair',
36
+ eye_color: 'blue',
37
+ birth_year: '19BBY',
38
+ gender: 'male',
39
+ homeworld: 'https://swapi.dev/api/planets/1/',
40
+ films: ['https://swapi.dev/api/films/1/'],
41
+ species: [],
42
+ vehicles: [],
43
+ starships: [],
44
+ created: '2014-12-09T13:50:51.644000Z',
45
+ edited: '2014-12-20T21:17:56.891000Z',
46
+ url: 'https://swapi.dev/api/people/1/',
47
+ },
48
+ },
49
+ durationMs: 50,
50
+ });
51
+
52
+ const fact = await client.getFact();
53
+
54
+ expect(fact.type).toBe(EStarWarsFactType.CHARACTER);
55
+ expect(fact.faction).toBe(EStarWarsFaction.UNKNOWN);
56
+ expect(fact.character).toBe('Luke Skywalker');
57
+ expect(fact.content).toBe(
58
+ 'Luke Skywalker is a male character born in 19BBY, standing 172cm tall.'
59
+ );
60
+ expect(fact.isSafe).toBe(true);
61
+ expect(fact.id).toBeDefined();
62
+ });
63
+
64
+ it('generates a deterministic id from character name', async () => {
65
+ const personData = {
66
+ name: 'Darth Vader',
67
+ height: '202',
68
+ mass: '136',
69
+ hair_color: 'none',
70
+ skin_color: 'white',
71
+ eye_color: 'yellow',
72
+ birth_year: '41.9BBY',
73
+ gender: 'male',
74
+ homeworld: 'https://swapi.dev/api/planets/1/',
75
+ films: [],
76
+ species: [],
77
+ vehicles: [],
78
+ starships: [],
79
+ created: '2014-12-10T15:18:20.704000Z',
80
+ edited: '2014-12-20T21:17:50.313000Z',
81
+ url: 'https://swapi.dev/api/people/4/',
82
+ };
83
+
84
+ mockHandleRequest.mockResolvedValue({
85
+ response: { data: personData },
86
+ durationMs: 10,
87
+ });
88
+
89
+ const fact1 = await client.getFact();
90
+ const fact2 = await client.getFact();
91
+
92
+ expect(fact1.id).toBe(fact2.id);
93
+ });
94
+
95
+ it('calls handleRequest with GET /people/{id}/', async () => {
96
+ mockHandleRequest.mockResolvedValueOnce({
97
+ response: {
98
+ data: {
99
+ name: 'C-3PO',
100
+ height: '167',
101
+ mass: '75',
102
+ hair_color: 'n/a',
103
+ skin_color: 'gold',
104
+ eye_color: 'yellow',
105
+ birth_year: '112BBY',
106
+ gender: 'n/a',
107
+ homeworld: 'https://swapi.dev/api/planets/1/',
108
+ films: [],
109
+ species: [],
110
+ vehicles: [],
111
+ starships: [],
112
+ created: '2014-12-10T15:10:51.357000Z',
113
+ edited: '2014-12-20T21:17:50.309000Z',
114
+ url: 'https://swapi.dev/api/people/2/',
115
+ },
116
+ },
117
+ durationMs: 10,
118
+ });
119
+
120
+ await client.getFact();
121
+
122
+ expect(mockHandleRequest).toHaveBeenCalledWith(
123
+ expect.objectContaining({ method: 'GET', url: expect.stringMatching(/^\/people\/\d+\/$/) }),
124
+ expect.objectContaining({ method: 'GET', url: expect.stringMatching(/^\/people\/\d+\/$/) })
125
+ );
126
+ });
127
+
128
+ it('propagates errors from handleRequest', async () => {
129
+ mockHandleRequest.mockRejectedValueOnce(new Error('Network error'));
130
+
131
+ await expect(client.getFact()).rejects.toThrow('Network error');
132
+ });
133
+ });
134
+ });
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
+ }