@thalorlabs/swquotes 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,34 @@
1
+ # CLAUDE.md — @thalorlabs/swquotes
2
+
3
+ ## What this is
4
+
5
+ Provider adapter for swquotesapi.digitaljedi.dk (Star Wars Quotes API). Returns `TLStarWarsFact`.
6
+
7
+ ## External API
8
+
9
+ - swquotesapi.digitaljedi.dk — free, no auth, no billing
10
+ - Returns random Star Wars quotes with character attribution
11
+ - HTTP only (not HTTPS)
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 quote response to TLStarWarsFact
26
+ - Parses character name from quote text
27
+ - Maps numeric faction codes to EStarWarsFaction enum
28
+ - Sets: type=QUOTE, isSafe=true
29
+
30
+ ## What this package does NOT do
31
+
32
+ - No database, no caching, no logging — orchestrator handles that
33
+ - Raw types never exported
34
+ - No provider selection logic
@@ -0,0 +1,21 @@
1
+ import { BaseHttpClient } from '@thalorlabs/api';
2
+ import { TLStarWarsFact } from '@thalorlabs/types';
3
+ export interface SwQuotesClientConfig {
4
+ baseURL?: string;
5
+ timeout?: number;
6
+ }
7
+ /**
8
+ * HTTP adapter for swquotesapi.digitaljedi.dk.
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 SwQuotesClient extends BaseHttpClient {
15
+ readonly serviceName = "swquotes";
16
+ constructor(config?: SwQuotesClientConfig);
17
+ getFact(): Promise<TLStarWarsFact>;
18
+ private normalise;
19
+ private parseQuote;
20
+ private mapFaction;
21
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SwQuotesClient = 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 swquotesapi.digitaljedi.dk.
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 SwQuotesClient extends api_1.BaseHttpClient {
15
+ constructor(config = {}) {
16
+ super({
17
+ baseURL: config.baseURL ?? 'http://swquotesapi.digitaljedi.dk',
18
+ serviceName: 'swquotes',
19
+ retries: 3,
20
+ timeout: config.timeout ?? 10000,
21
+ });
22
+ this.serviceName = 'swquotes';
23
+ }
24
+ async getFact() {
25
+ const { response } = await this.handleRequest({ method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote' }, { method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote', requestId: (0, crypto_1.randomUUID)() });
26
+ return this.normalise(response.data);
27
+ }
28
+ normalise(raw) {
29
+ const { content, character } = this.parseQuote(raw.starWarsQuote);
30
+ return types_1.TLStarWarsFact.parse({
31
+ id: (0, crypto_1.createHash)('md5').update(raw.starWarsQuote).digest('hex'),
32
+ type: types_1.EStarWarsFactType.QUOTE,
33
+ content,
34
+ character,
35
+ faction: this.mapFaction(raw.faction),
36
+ film: undefined,
37
+ isSafe: true,
38
+ });
39
+ }
40
+ parseQuote(quote) {
41
+ // Try " — " first (em-dash style), then " - " (hyphen style)
42
+ const emDashIndex = quote.lastIndexOf(' — ');
43
+ if (emDashIndex !== -1) {
44
+ return {
45
+ content: quote.substring(0, emDashIndex).trim(),
46
+ character: quote.substring(emDashIndex + 3).trim(),
47
+ };
48
+ }
49
+ const hyphenIndex = quote.lastIndexOf(' - ');
50
+ if (hyphenIndex !== -1) {
51
+ return {
52
+ content: quote.substring(0, hyphenIndex).trim(),
53
+ character: quote.substring(hyphenIndex + 3).trim(),
54
+ };
55
+ }
56
+ return { content: quote };
57
+ }
58
+ mapFaction(faction) {
59
+ switch (faction) {
60
+ case 0:
61
+ return types_1.EStarWarsFaction.JEDI_ORDER;
62
+ case 1:
63
+ return types_1.EStarWarsFaction.SITH;
64
+ case 3:
65
+ return types_1.EStarWarsFaction.REBEL_ALLIANCE;
66
+ case 2:
67
+ case 4:
68
+ default:
69
+ return types_1.EStarWarsFaction.UNKNOWN;
70
+ }
71
+ }
72
+ }
73
+ exports.SwQuotesClient = SwQuotesClient;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Raw response from swquotesapi.digitaljedi.dk API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface SwQuotesRawResponse {
8
+ id: number;
9
+ starWarsQuote: string;
10
+ faction: number;
11
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ export { SwQuotesClient, SwQuotesClientConfig } from './SwQuotesClient';
2
+ export declare const SWQUOTES_CONFIG: {
3
+ readonly cacheTtlMs: 0;
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.SWQUOTES_CONFIG = exports.SwQuotesClient = void 0;
4
+ var SwQuotesClient_1 = require("./SwQuotesClient");
5
+ Object.defineProperty(exports, "SwQuotesClient", { enumerable: true, get: function () { return SwQuotesClient_1.SwQuotesClient; } });
6
+ exports.SWQUOTES_CONFIG = {
7
+ cacheTtlMs: 0, // random endpoint — no caching
8
+ isBillable: false, // swquotesapi is free
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/swquotes",
3
+ "author": "ThalorLabs",
4
+ "private": false,
5
+ "version": "1.0.0",
6
+ "description": "Provider adapter for swquotesapi.digitaljedi.dk — returns TLStarWarsFact",
7
+ "homepage": "https://github.com/ThalorLabs/swquotes#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ThalorLabs/swquotes/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ThalorLabs/swquotes.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,89 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+
3
+ import { BaseHttpClient } from '@thalorlabs/api';
4
+ import { EStarWarsFaction, EStarWarsFactType, TLStarWarsFact } from '@thalorlabs/types';
5
+
6
+ import { SwQuotesRawResponse } from './SwQuotesTypes';
7
+
8
+ export interface SwQuotesClientConfig {
9
+ baseURL?: string;
10
+ timeout?: number;
11
+ }
12
+
13
+ /**
14
+ * HTTP adapter for swquotesapi.digitaljedi.dk.
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 SwQuotesClient extends BaseHttpClient {
21
+ public readonly serviceName = 'swquotes';
22
+
23
+ constructor(config: SwQuotesClientConfig = {}) {
24
+ super({
25
+ baseURL: config.baseURL ?? 'http://swquotesapi.digitaljedi.dk',
26
+ serviceName: 'swquotes',
27
+ retries: 3,
28
+ timeout: config.timeout ?? 10000,
29
+ });
30
+ }
31
+
32
+ async getFact(): Promise<TLStarWarsFact> {
33
+ const { response } = await this.handleRequest<SwQuotesRawResponse>(
34
+ { method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote' },
35
+ { method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote', requestId: randomUUID() }
36
+ );
37
+ return this.normalise(response.data);
38
+ }
39
+
40
+ private normalise(raw: SwQuotesRawResponse): TLStarWarsFact {
41
+ const { content, character } = this.parseQuote(raw.starWarsQuote);
42
+
43
+ return TLStarWarsFact.parse({
44
+ id: createHash('md5').update(raw.starWarsQuote).digest('hex'),
45
+ type: EStarWarsFactType.QUOTE,
46
+ content,
47
+ character,
48
+ faction: this.mapFaction(raw.faction),
49
+ film: undefined,
50
+ isSafe: true,
51
+ });
52
+ }
53
+
54
+ private parseQuote(quote: string): { content: string; character?: string } {
55
+ // Try " — " first (em-dash style), then " - " (hyphen style)
56
+ const emDashIndex = quote.lastIndexOf(' — ');
57
+ if (emDashIndex !== -1) {
58
+ return {
59
+ content: quote.substring(0, emDashIndex).trim(),
60
+ character: quote.substring(emDashIndex + 3).trim(),
61
+ };
62
+ }
63
+
64
+ const hyphenIndex = quote.lastIndexOf(' - ');
65
+ if (hyphenIndex !== -1) {
66
+ return {
67
+ content: quote.substring(0, hyphenIndex).trim(),
68
+ character: quote.substring(hyphenIndex + 3).trim(),
69
+ };
70
+ }
71
+
72
+ return { content: quote };
73
+ }
74
+
75
+ private mapFaction(faction: number): EStarWarsFaction {
76
+ switch (faction) {
77
+ case 0:
78
+ return EStarWarsFaction.JEDI_ORDER;
79
+ case 1:
80
+ return EStarWarsFaction.SITH;
81
+ case 3:
82
+ return EStarWarsFaction.REBEL_ALLIANCE;
83
+ case 2:
84
+ case 4:
85
+ default:
86
+ return EStarWarsFaction.UNKNOWN;
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Raw response from swquotesapi.digitaljedi.dk API.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface SwQuotesRawResponse {
8
+ id: number;
9
+ starWarsQuote: string;
10
+ faction: number;
11
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { SwQuotesClient, SwQuotesClientConfig } from './SwQuotesClient';
2
+
3
+ export const SWQUOTES_CONFIG = {
4
+ cacheTtlMs: 0, // random endpoint — no caching
5
+ isBillable: false, // swquotesapi is free
6
+ } as const;
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SwQuotesClient } from '../src/SwQuotesClient';
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('SwQuotesClient', () => {
13
+ let client: SwQuotesClient;
14
+
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ client = new SwQuotesClient();
18
+ });
19
+
20
+ describe('constructor', () => {
21
+ it('sets serviceName to swquotes', () => {
22
+ expect(client.serviceName).toBe('swquotes');
23
+ });
24
+ });
25
+
26
+ describe('getFact', () => {
27
+ it('returns a valid TLStarWarsFact with QUOTE type', async () => {
28
+ mockHandleRequest.mockResolvedValueOnce({
29
+ response: {
30
+ data: {
31
+ id: 7,
32
+ starWarsQuote: 'Never tell me the odds! — Han Solo',
33
+ faction: 4,
34
+ },
35
+ },
36
+ durationMs: 50,
37
+ });
38
+
39
+ const fact = await client.getFact();
40
+
41
+ expect(fact.type).toBe(EStarWarsFactType.QUOTE);
42
+ expect(fact.content).toBe('Never tell me the odds!');
43
+ expect(fact.character).toBe('Han Solo');
44
+ expect(fact.isSafe).toBe(true);
45
+ expect(fact.id).toBeDefined();
46
+ });
47
+
48
+ it('parses character name from em-dash separator', async () => {
49
+ mockHandleRequest.mockResolvedValueOnce({
50
+ response: {
51
+ data: {
52
+ id: 1,
53
+ starWarsQuote: 'Do. Or do not. There is no try. — Yoda',
54
+ faction: 0,
55
+ },
56
+ },
57
+ durationMs: 10,
58
+ });
59
+
60
+ const fact = await client.getFact();
61
+
62
+ expect(fact.content).toBe('Do. Or do not. There is no try.');
63
+ expect(fact.character).toBe('Yoda');
64
+ });
65
+
66
+ it('parses character name from hyphen separator', async () => {
67
+ mockHandleRequest.mockResolvedValueOnce({
68
+ response: {
69
+ data: {
70
+ id: 2,
71
+ starWarsQuote: 'I am your father - Darth Vader',
72
+ faction: 1,
73
+ },
74
+ },
75
+ durationMs: 10,
76
+ });
77
+
78
+ const fact = await client.getFact();
79
+
80
+ expect(fact.content).toBe('I am your father');
81
+ expect(fact.character).toBe('Darth Vader');
82
+ });
83
+
84
+ it('handles quotes without character attribution', async () => {
85
+ mockHandleRequest.mockResolvedValueOnce({
86
+ response: {
87
+ data: {
88
+ id: 3,
89
+ starWarsQuote: 'A long time ago in a galaxy far, far away...',
90
+ faction: 2,
91
+ },
92
+ },
93
+ durationMs: 10,
94
+ });
95
+
96
+ const fact = await client.getFact();
97
+
98
+ expect(fact.content).toBe('A long time ago in a galaxy far, far away...');
99
+ expect(fact.character).toBeUndefined();
100
+ });
101
+
102
+ it('maps faction 0 to JEDI_ORDER', async () => {
103
+ mockHandleRequest.mockResolvedValueOnce({
104
+ response: {
105
+ data: { id: 10, starWarsQuote: 'The Force is strong — Yoda', faction: 0 },
106
+ },
107
+ durationMs: 10,
108
+ });
109
+
110
+ const fact = await client.getFact();
111
+ expect(fact.faction).toBe(EStarWarsFaction.JEDI_ORDER);
112
+ });
113
+
114
+ it('maps faction 1 to SITH', async () => {
115
+ mockHandleRequest.mockResolvedValueOnce({
116
+ response: {
117
+ data: { id: 11, starWarsQuote: 'The dark side — Palpatine', faction: 1 },
118
+ },
119
+ durationMs: 10,
120
+ });
121
+
122
+ const fact = await client.getFact();
123
+ expect(fact.faction).toBe(EStarWarsFaction.SITH);
124
+ });
125
+
126
+ it('maps faction 3 to REBEL_ALLIANCE', async () => {
127
+ mockHandleRequest.mockResolvedValueOnce({
128
+ response: {
129
+ data: { id: 12, starWarsQuote: 'May the Force be with you — Leia', faction: 3 },
130
+ },
131
+ durationMs: 10,
132
+ });
133
+
134
+ const fact = await client.getFact();
135
+ expect(fact.faction).toBe(EStarWarsFaction.REBEL_ALLIANCE);
136
+ });
137
+
138
+ it('maps faction 2 and 4 to UNKNOWN', async () => {
139
+ mockHandleRequest.mockResolvedValueOnce({
140
+ response: {
141
+ data: { id: 13, starWarsQuote: 'Beep boop — R2-D2', faction: 2 },
142
+ },
143
+ durationMs: 10,
144
+ });
145
+
146
+ const fact = await client.getFact();
147
+ expect(fact.faction).toBe(EStarWarsFaction.UNKNOWN);
148
+ });
149
+
150
+ it('generates a deterministic id from quote text', async () => {
151
+ const quoteText = 'Never tell me the odds! — Han Solo';
152
+ mockHandleRequest.mockResolvedValue({
153
+ response: { data: { id: 7, starWarsQuote: quoteText, faction: 4 } },
154
+ durationMs: 10,
155
+ });
156
+
157
+ const fact1 = await client.getFact();
158
+ const fact2 = await client.getFact();
159
+
160
+ expect(fact1.id).toBe(fact2.id);
161
+ });
162
+
163
+ it('calls handleRequest with GET /api/SWQuote/RandomStarWarsQuote', async () => {
164
+ mockHandleRequest.mockResolvedValueOnce({
165
+ response: {
166
+ data: { id: 1, starWarsQuote: 'A quote — Someone', faction: 0 },
167
+ },
168
+ durationMs: 10,
169
+ });
170
+
171
+ await client.getFact();
172
+
173
+ expect(mockHandleRequest).toHaveBeenCalledWith(
174
+ expect.objectContaining({ method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote' }),
175
+ expect.objectContaining({ method: 'GET', url: '/api/SWQuote/RandomStarWarsQuote' })
176
+ );
177
+ });
178
+
179
+ it('propagates errors from handleRequest', async () => {
180
+ mockHandleRequest.mockRejectedValueOnce(new Error('Network error'));
181
+
182
+ await expect(client.getFact()).rejects.toThrow('Network error');
183
+ });
184
+ });
185
+ });
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
+ }