@horka/app-forge 0.1.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/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ApiError } from '../errors/ApiError';
|
|
2
|
+
import type { HttpTransport } from '../core/HttpClient';
|
|
3
|
+
import type { SDKContext } from '../core/SDKContext';
|
|
4
|
+
import { noopLogger, type SDKLogger } from '../core/Logger';
|
|
5
|
+
import { withTimeout } from '../core/withTimeout';
|
|
6
|
+
import type { HttpMethod, TokenPair } from '../types/index';
|
|
7
|
+
|
|
8
|
+
const ACCESS_TOKEN_KEY = 'access_token';
|
|
9
|
+
const REFRESH_TOKEN_KEY = 'refresh_token';
|
|
10
|
+
const DEFAULT_REFRESH_PATH = '/auth/refresh';
|
|
11
|
+
const DEFAULT_REFRESH_TIMEOUT_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export interface AuthClientOptions {
|
|
14
|
+
/** Refresh endpoint path. Default: `/auth/refresh`. */
|
|
15
|
+
refreshPath?: string;
|
|
16
|
+
/** Upper bound on how long any request may wait for a refresh. Default: 10s. */
|
|
17
|
+
refreshTimeoutMs?: number;
|
|
18
|
+
logger?: SDKLogger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Secure-request use case with SINGLE-FLIGHT token refresh (SDK_CONTRACT.md §3).
|
|
23
|
+
*
|
|
24
|
+
* Refresh tokens are rotating and single-use: when N concurrent requests hit 401,
|
|
25
|
+
* exactly ONE refresh call may go out — the first failure starts it, every other
|
|
26
|
+
* waiter joins the same inflight promise. Without this, N−1 refreshes burn an
|
|
27
|
+
* already-rotated token and randomly log users out (a real production bug; the
|
|
28
|
+
* regression test in tests/singleFlight.test.ts is non-negotiable).
|
|
29
|
+
*
|
|
30
|
+
* Invariants — do not "simplify" any of these away:
|
|
31
|
+
* - one shared inflight promise; latecomers await it
|
|
32
|
+
* - the wait is bounded (withTimeout) — waiters reject, they never hang
|
|
33
|
+
* - the original request is retried exactly ONCE after a successful refresh
|
|
34
|
+
* - every waiter is failed on any refresh failure (a half-authenticated session is worse
|
|
35
|
+
* than a clean error), BUT tokens are cleared ONLY on a real auth rejection (401 /
|
|
36
|
+
* invalid_grant). A transport failure (offline, DNS, refused, timeout, 5xx) means the
|
|
37
|
+
* refresh never reached a verdict — clearing tokens there forces a gratuitous logout on
|
|
38
|
+
* a session that may still be valid. Network failures propagate; the session survives.
|
|
39
|
+
* - the refresh call uses the raw transport, never request() (no recursion on 401)
|
|
40
|
+
*/
|
|
41
|
+
export class AuthClient {
|
|
42
|
+
private refreshing: Promise<void> | null = null;
|
|
43
|
+
private readonly refreshPath: string;
|
|
44
|
+
private readonly refreshTimeoutMs: number;
|
|
45
|
+
private readonly logger: SDKLogger;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly http: HttpTransport,
|
|
49
|
+
private readonly context: SDKContext,
|
|
50
|
+
options: AuthClientOptions = {},
|
|
51
|
+
) {
|
|
52
|
+
this.refreshPath = options.refreshPath ?? DEFAULT_REFRESH_PATH;
|
|
53
|
+
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
|
54
|
+
this.logger = options.logger ?? noopLogger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Wire the tokens obtained by the consumer's login flow into the SDK. */
|
|
58
|
+
setTokens(tokens: TokenPair): void {
|
|
59
|
+
this.context.setCookie(ACCESS_TOKEN_KEY, tokens.accessToken);
|
|
60
|
+
this.context.setCookie(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clearTokens(): void {
|
|
64
|
+
this.context.removeCookie(ACCESS_TOKEN_KEY);
|
|
65
|
+
this.context.removeCookie(REFRESH_TOKEN_KEY);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getAccessToken(): string | null {
|
|
69
|
+
return this.context.getCookie(ACCESS_TOKEN_KEY);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An authenticated request: 401 → join/start the single-flight refresh → retry once. */
|
|
73
|
+
async request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T> {
|
|
74
|
+
const attempt = async (): Promise<T> => {
|
|
75
|
+
const token = this.getAccessToken();
|
|
76
|
+
if (!token) {
|
|
77
|
+
throw new ApiError({
|
|
78
|
+
code: 401,
|
|
79
|
+
name: 'MissingToken',
|
|
80
|
+
description: 'No access token available — authenticate first',
|
|
81
|
+
statusCode: 401,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return this.http.request<T>(method, path, { token, body });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
return await attempt();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!AuthClient.isUnauthorized(error)) throw error;
|
|
91
|
+
this.logger.debug('401 received — joining token refresh', { path });
|
|
92
|
+
await this.refreshTokens();
|
|
93
|
+
return attempt(); // retry exactly ONCE with the refreshed token
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Single-flight: all concurrent 401s share one inflight refresh promise. */
|
|
98
|
+
private refreshTokens(): Promise<void> {
|
|
99
|
+
if (!this.refreshing) {
|
|
100
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
101
|
+
this.refreshing = null;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return this.refreshing;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async doRefresh(): Promise<void> {
|
|
108
|
+
const refreshToken = this.context.getCookie(REFRESH_TOKEN_KEY);
|
|
109
|
+
if (!refreshToken) {
|
|
110
|
+
this.clearTokens();
|
|
111
|
+
throw new ApiError({
|
|
112
|
+
code: 401,
|
|
113
|
+
name: 'MissingRefreshToken',
|
|
114
|
+
description: 'No refresh token available — cannot refresh the session',
|
|
115
|
+
statusCode: 401,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const pair = await withTimeout(
|
|
121
|
+
// Raw transport on purpose: a 401 from the refresh endpoint must not recurse.
|
|
122
|
+
this.http.request<TokenPair>('POST', this.refreshPath, { body: { refreshToken } }),
|
|
123
|
+
this.refreshTimeoutMs,
|
|
124
|
+
() =>
|
|
125
|
+
new ApiError({
|
|
126
|
+
code: 408,
|
|
127
|
+
name: 'RefreshTimeout',
|
|
128
|
+
description: `Token refresh timed out after ${this.refreshTimeoutMs}ms`,
|
|
129
|
+
statusCode: 408,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
this.setTokens(pair);
|
|
133
|
+
this.logger.debug('token refresh succeeded');
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Fail every waiter regardless — a half-authenticated session is worse than a clean
|
|
136
|
+
// error. But only DROP the session when the server actually rejected the refresh token
|
|
137
|
+
// (401 / invalid_grant). A transport failure (offline/DNS/refused/timeout/5xx) never
|
|
138
|
+
// reached a verdict: clearing here would log the user out over a flaky network.
|
|
139
|
+
if (AuthClient.isAuthRejection(error)) {
|
|
140
|
+
this.clearTokens();
|
|
141
|
+
this.logger.error('token refresh rejected — tokens cleared');
|
|
142
|
+
} else {
|
|
143
|
+
this.logger.error('token refresh failed (transport) — tokens kept');
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static isUnauthorized(error: unknown): boolean {
|
|
150
|
+
return error instanceof ApiError && error.statusCode === 401;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True only for a genuine auth rejection from the refresh endpoint — a 401, or a 400
|
|
155
|
+
* carrying the OAuth `invalid_grant` code. Timeouts (RefreshTimeout, 408), 5xx and raw
|
|
156
|
+
* network errors are transport failures, NOT a verdict on the refresh token.
|
|
157
|
+
*/
|
|
158
|
+
private static isAuthRejection(error: unknown): boolean {
|
|
159
|
+
if (!(error instanceof ApiError)) return false;
|
|
160
|
+
if (error.statusCode === 401) return true;
|
|
161
|
+
if (error.statusCode === 400) {
|
|
162
|
+
// OAuth refresh rejection: the wire body's name is `invalid_grant`, or the raw body
|
|
163
|
+
// mentions it (servers vary). A plain 400 without that signal is NOT an auth verdict.
|
|
164
|
+
return error.errorName === 'invalid_grant' || /invalid_grant/i.test(error.rawMessage ?? '');
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ApiError } from '../errors/ApiError';
|
|
2
|
+
import type { HttpMethod } from '../types/index';
|
|
3
|
+
import { noopLogger, type SDKLogger } from './Logger';
|
|
4
|
+
|
|
5
|
+
export interface RequestOptions {
|
|
6
|
+
/** Bearer token added as the Authorization header when present. */
|
|
7
|
+
token?: string | null;
|
|
8
|
+
/** JSON-serialized unless it is FormData. */
|
|
9
|
+
body?: unknown;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Transport port. Clients and use cases depend on THIS interface, never on
|
|
15
|
+
* `fetch` — tests stub it, alternative transports implement it.
|
|
16
|
+
*/
|
|
17
|
+
export interface HttpTransport {
|
|
18
|
+
request<T>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HttpClientOptions {
|
|
22
|
+
baseURL: string;
|
|
23
|
+
/** Injected for tests/polyfills; defaults to the global fetch (Node 20+/browsers). */
|
|
24
|
+
fetchFn?: typeof fetch;
|
|
25
|
+
logger?: SDKLogger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The fetch adapter (L2). Single responsibility: send the request, normalize
|
|
30
|
+
* every non-2xx response into an ApiError. No retries, no auth logic — that
|
|
31
|
+
* lives in clients/AuthClient.ts.
|
|
32
|
+
*/
|
|
33
|
+
export class HttpClient implements HttpTransport {
|
|
34
|
+
private readonly baseURL: string;
|
|
35
|
+
private readonly fetchFn: typeof fetch;
|
|
36
|
+
private readonly logger: SDKLogger;
|
|
37
|
+
|
|
38
|
+
constructor(options: HttpClientOptions) {
|
|
39
|
+
this.baseURL = options.baseURL.replace(/\/+$/, '');
|
|
40
|
+
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
41
|
+
this.logger = options.logger ?? noopLogger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async request<T>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
|
|
45
|
+
const headers: Record<string, string> = { Accept: 'application/json', ...options.headers };
|
|
46
|
+
if (options.token) headers['Authorization'] = `Bearer ${options.token}`;
|
|
47
|
+
|
|
48
|
+
let body: BodyInit | undefined;
|
|
49
|
+
if (options.body !== undefined) {
|
|
50
|
+
if (options.body instanceof FormData) {
|
|
51
|
+
body = options.body;
|
|
52
|
+
} else {
|
|
53
|
+
headers['Content-Type'] = 'application/json';
|
|
54
|
+
body = JSON.stringify(options.body);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Method + path only — never headers, never token material (CONVENTIONS_TS.md §4).
|
|
59
|
+
this.logger.debug('http request', { method, path });
|
|
60
|
+
|
|
61
|
+
const response = await this.fetchFn(`${this.baseURL}${path}`, { method, headers, body });
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw ApiError.fromResponse(response.status, await response.text());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No-body responses: 204/205, or any 2xx whose body is empty or not JSON. Calling
|
|
67
|
+
// response.json() on those throws a raw SyntaxError that escapes the ApiError contract
|
|
68
|
+
// (consumers expect either T or an ApiError, never a SyntaxError). Read the text once,
|
|
69
|
+
// return undefined when there is nothing to parse, parse only genuine JSON.
|
|
70
|
+
if (response.status === 204 || response.status === 205) return undefined as T;
|
|
71
|
+
|
|
72
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
73
|
+
const text = await response.text();
|
|
74
|
+
if (text.length === 0) return undefined as T;
|
|
75
|
+
if (!contentType.includes('json')) return undefined as T;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text) as T;
|
|
79
|
+
} catch {
|
|
80
|
+
// A JSON content-type with an unparseable body is a malformed success response;
|
|
81
|
+
// surface it as a typed ApiError instead of leaking the SyntaxError.
|
|
82
|
+
throw ApiError.fromResponse(response.status, text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops port — injected logger, silent by default (SDK_CONTRACT.md §5).
|
|
3
|
+
*
|
|
4
|
+
* Zero direct console calls anywhere in src/: the consumer owns the output and the switch.
|
|
5
|
+
* NEVER log token values, Authorization headers, or token-presence booleans —
|
|
6
|
+
* a production team once mapped its whole auth topology into every consumer's
|
|
7
|
+
* devtools that way. Log method + path, nothing from headers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SDKLogger {
|
|
11
|
+
debug(message: string, meta?: Record<string, unknown>): void;
|
|
12
|
+
error(message: string, meta?: Record<string, unknown>): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const noopLogger: SDKLogger = {
|
|
16
|
+
debug: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Gate `debug()` behind the consumer's `debug` flag; errors always pass through. */
|
|
21
|
+
export function gateDebug(logger: SDKLogger, debug: boolean): SDKLogger {
|
|
22
|
+
if (debug) return logger;
|
|
23
|
+
return {
|
|
24
|
+
debug: () => {},
|
|
25
|
+
error: (message, meta) => logger.error(message, meta),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage port — the SDK never touches `document.cookie` or framework internals
|
|
3
|
+
* directly (SDK_CONTRACT.md §3, "Storage is an injected port").
|
|
4
|
+
*
|
|
5
|
+
* The skeleton ships the in-memory adapter only (tests, CLI, server-side jobs).
|
|
6
|
+
* Adding a browser or SSR adapter is a recorded decision: read the token-storage
|
|
7
|
+
* tradeoff table in SDK_CONTRACT.md §3 and write the choice + mitigations into
|
|
8
|
+
* `.claude/memory/DECISIONS.md` — never inherit a default silently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CookieOptions {
|
|
12
|
+
path?: string;
|
|
13
|
+
maxAge?: number;
|
|
14
|
+
domain?: string;
|
|
15
|
+
secure?: boolean;
|
|
16
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SDKContext {
|
|
20
|
+
getCookie(name: string): string | null;
|
|
21
|
+
setCookie(name: string, value: string, options?: CookieOptions): void;
|
|
22
|
+
removeCookie(name: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* In-memory adapter: process-local, non-persistent.
|
|
27
|
+
* The default context — and the reference implementation for any other adapter.
|
|
28
|
+
*/
|
|
29
|
+
export function createMemoryContext(): SDKContext {
|
|
30
|
+
const store = new Map<string, string>();
|
|
31
|
+
return {
|
|
32
|
+
getCookie: (name) => store.get(name) ?? null,
|
|
33
|
+
setCookie: (name, value) => {
|
|
34
|
+
store.set(name, value);
|
|
35
|
+
},
|
|
36
|
+
removeCookie: (name) => {
|
|
37
|
+
store.delete(name);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound a promise with a timeout — used to guarantee that token-refresh waiters
|
|
3
|
+
* are never parked forever (SDK_CONTRACT.md §3: "always bound the wait").
|
|
4
|
+
*/
|
|
5
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, makeError: () => Error): Promise<T> {
|
|
6
|
+
return new Promise<T>((resolve, reject) => {
|
|
7
|
+
const timer = setTimeout(() => reject(makeError()), ms);
|
|
8
|
+
promise.then(
|
|
9
|
+
(value) => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
resolve(value);
|
|
12
|
+
},
|
|
13
|
+
(error: unknown) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ApiErrorBody } from '../types/index';
|
|
2
|
+
|
|
3
|
+
interface ApiErrorDetails extends ApiErrorBody {
|
|
4
|
+
statusCode: number;
|
|
5
|
+
rawMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const STATUS_NAMES: Record<number, string> = {
|
|
9
|
+
400: 'BadRequest',
|
|
10
|
+
401: 'Unauthorized',
|
|
11
|
+
403: 'Forbidden',
|
|
12
|
+
404: 'NotFound',
|
|
13
|
+
408: 'RequestTimeout',
|
|
14
|
+
409: 'Conflict',
|
|
15
|
+
422: 'UnprocessableEntity',
|
|
16
|
+
429: 'TooManyRequests',
|
|
17
|
+
500: 'InternalServerError',
|
|
18
|
+
502: 'BadGateway',
|
|
19
|
+
503: 'ServiceUnavailable',
|
|
20
|
+
504: 'GatewayTimeout',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ONE error type every non-2xx response becomes (SDK_CONTRACT.md §2).
|
|
25
|
+
* The transport throws it; consumers map `statusCode` to UX in one shared handler.
|
|
26
|
+
* Nothing in this SDK ever swallows a status code or re-parses a response body.
|
|
27
|
+
*/
|
|
28
|
+
export class ApiError extends Error {
|
|
29
|
+
/** Application-level error code from the wire body (falls back to the HTTP status). */
|
|
30
|
+
public readonly code: number;
|
|
31
|
+
/** Machine-readable error name (`Unauthorized`, `Conflict`, …). */
|
|
32
|
+
public readonly errorName: string;
|
|
33
|
+
/** The HTTP status code of the response. */
|
|
34
|
+
public readonly statusCode: number;
|
|
35
|
+
/** The raw response text, for diagnostics only — never re-parse it downstream. */
|
|
36
|
+
public readonly rawMessage?: string;
|
|
37
|
+
|
|
38
|
+
constructor(details: ApiErrorDetails) {
|
|
39
|
+
super(details.description);
|
|
40
|
+
this.name = 'ApiError';
|
|
41
|
+
this.code = details.code;
|
|
42
|
+
this.errorName = details.name;
|
|
43
|
+
this.statusCode = details.statusCode;
|
|
44
|
+
this.rawMessage = details.rawMessage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a non-2xx response into an ApiError:
|
|
49
|
+
* 1. try to parse the body as the wire contract `{ code, name, description }`;
|
|
50
|
+
* 2. on shape mismatch or parse failure, build a standard error from the status code.
|
|
51
|
+
*/
|
|
52
|
+
static fromResponse(statusCode: number, responseText: string): ApiError {
|
|
53
|
+
try {
|
|
54
|
+
const parsed: unknown = JSON.parse(responseText);
|
|
55
|
+
if (ApiError.isWireBody(parsed)) {
|
|
56
|
+
return new ApiError({ ...parsed, statusCode, rawMessage: responseText });
|
|
57
|
+
}
|
|
58
|
+
return ApiError.fromStatus(statusCode, responseText);
|
|
59
|
+
} catch {
|
|
60
|
+
return ApiError.fromStatus(statusCode, responseText);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static isWireBody(value: unknown): value is ApiErrorBody {
|
|
65
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
66
|
+
const body = value as Record<string, unknown>;
|
|
67
|
+
return (
|
|
68
|
+
typeof body['code'] === 'number' &&
|
|
69
|
+
typeof body['name'] === 'string' &&
|
|
70
|
+
typeof body['description'] === 'string'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static fromStatus(statusCode: number, message: string): ApiError {
|
|
75
|
+
const name = STATUS_NAMES[statusCode] ?? 'UnknownError';
|
|
76
|
+
return new ApiError({
|
|
77
|
+
code: statusCode,
|
|
78
|
+
name,
|
|
79
|
+
description: `${statusCode} ${name}${message ? ` - ${message}` : ''}`,
|
|
80
|
+
statusCode,
|
|
81
|
+
rawMessage: message,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
toJSON(): ApiErrorBody & { statusCode: number } {
|
|
86
|
+
return {
|
|
87
|
+
code: this.code,
|
|
88
|
+
name: this.errorName,
|
|
89
|
+
description: this.message,
|
|
90
|
+
statusCode: this.statusCode,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root (L5) — the ONLY file that constructs adapters and wires
|
|
3
|
+
* transport → auth → resource clients. It re-exports the entire public surface;
|
|
4
|
+
* anything not exported here (or from ./types) does not exist for consumers.
|
|
5
|
+
*/
|
|
6
|
+
import { AuthClient } from './clients/AuthClient';
|
|
7
|
+
import { HttpClient } from './core/HttpClient';
|
|
8
|
+
import { gateDebug, noopLogger, type SDKLogger } from './core/Logger';
|
|
9
|
+
import { createMemoryContext, type SDKContext } from './core/SDKContext';
|
|
10
|
+
|
|
11
|
+
export interface SDKOptions {
|
|
12
|
+
/** API origin, e.g. `https://api.example.com` (no trailing slash needed). */
|
|
13
|
+
baseURL: string;
|
|
14
|
+
/** Storage adapter. Defaults to the in-memory context — see SDKContext.ts. */
|
|
15
|
+
context?: SDKContext;
|
|
16
|
+
/** Consumer-owned logger; silent no-op by default. */
|
|
17
|
+
logger?: SDKLogger;
|
|
18
|
+
/** Enables logger.debug output. The CONSUMER owns this switch. */
|
|
19
|
+
debug?: boolean;
|
|
20
|
+
/** Injected fetch for tests/polyfills. */
|
|
21
|
+
fetchFn?: typeof fetch;
|
|
22
|
+
refreshPath?: string;
|
|
23
|
+
refreshTimeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class {{PROJECT_NAME}}Sdk {
|
|
27
|
+
/** Raw transport — for public (unauthenticated) endpoints. */
|
|
28
|
+
readonly http: HttpClient;
|
|
29
|
+
/** Authenticated requests with single-flight token refresh. */
|
|
30
|
+
readonly auth: AuthClient;
|
|
31
|
+
// Add resource clients as the API grows (docs-architecture/ARCHITECTURE.md §4):
|
|
32
|
+
// readonly projects: ProjectClient;
|
|
33
|
+
|
|
34
|
+
constructor(options: SDKOptions) {
|
|
35
|
+
const logger = gateDebug(options.logger ?? noopLogger, options.debug ?? false);
|
|
36
|
+
const context = options.context ?? createMemoryContext();
|
|
37
|
+
this.http = new HttpClient({ baseURL: options.baseURL, fetchFn: options.fetchFn, logger });
|
|
38
|
+
this.auth = new AuthClient(this.http, context, {
|
|
39
|
+
logger,
|
|
40
|
+
refreshPath: options.refreshPath,
|
|
41
|
+
refreshTimeoutMs: options.refreshTimeoutMs,
|
|
42
|
+
});
|
|
43
|
+
// this.projects = new ProjectClient(this.auth);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createSdk(options: SDKOptions): {{PROJECT_NAME}}Sdk {
|
|
48
|
+
return new {{PROJECT_NAME}}Sdk(options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Public surface (keep scripts/verify-dist.mjs in sync) ──────────────────
|
|
52
|
+
export { ApiError } from './errors/ApiError';
|
|
53
|
+
export { AuthClient, type AuthClientOptions } from './clients/AuthClient';
|
|
54
|
+
export {
|
|
55
|
+
HttpClient,
|
|
56
|
+
type HttpTransport,
|
|
57
|
+
type RequestOptions,
|
|
58
|
+
type HttpClientOptions,
|
|
59
|
+
} from './core/HttpClient';
|
|
60
|
+
export { createMemoryContext, type SDKContext, type CookieOptions } from './core/SDKContext';
|
|
61
|
+
export { noopLogger, type SDKLogger } from './core/Logger';
|
|
62
|
+
export type { ApiErrorBody, HttpMethod, TokenPair } from './types/index';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types — the shapes that cross the network, mirroring the backend DTOs.
|
|
3
|
+
*
|
|
4
|
+
* RULES (docs-architecture/ARCHITECTURE.md §2):
|
|
5
|
+
* - This module imports NOTHING else in the package (it is the SDK's L0).
|
|
6
|
+
* - Declarations only: interfaces, type aliases, string-literal unions. No runtime code.
|
|
7
|
+
* - Plain .ts, never .d.ts (declaration generators skip .d.ts sources silently).
|
|
8
|
+
*
|
|
9
|
+
* Consumers reach these through the types-only subpath:
|
|
10
|
+
* import type { TokenPair } from '{{BUNDLE_ID}}/types'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** HTTP methods the transport accepts. */
|
|
14
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
15
|
+
|
|
16
|
+
/** Auth token pair returned by the refresh endpoint. Refresh tokens are single-use. */
|
|
17
|
+
export interface TokenPair {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
refreshToken: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The frozen wire shape of an API error body.
|
|
24
|
+
* Renaming a field here is a breaking change in TWO repos (SDK_CONTRACT.md §2).
|
|
25
|
+
*/
|
|
26
|
+
export interface ApiErrorBody {
|
|
27
|
+
code: number;
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Add resource wire types below (one file per resource once a resource has >1 type),
|
|
33
|
+
// and re-export them from this index so they ship through "./types".
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { ApiError } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('ApiError.fromResponse — error normalization (SDK_CONTRACT.md §2)', () => {
|
|
5
|
+
test('parses the wire contract { code, name, description }', () => {
|
|
6
|
+
const body = JSON.stringify({ code: 4091, name: 'TeamAlreadyExists', description: 'A team with this name exists' });
|
|
7
|
+
|
|
8
|
+
const error = ApiError.fromResponse(409, body);
|
|
9
|
+
|
|
10
|
+
expect(error).toBeInstanceOf(ApiError);
|
|
11
|
+
expect(error).toBeInstanceOf(Error);
|
|
12
|
+
expect(error.name).toBe('ApiError');
|
|
13
|
+
expect(error.code).toBe(4091);
|
|
14
|
+
expect(error.errorName).toBe('TeamAlreadyExists');
|
|
15
|
+
expect(error.message).toBe('A team with this name exists');
|
|
16
|
+
expect(error.statusCode).toBe(409);
|
|
17
|
+
expect(error.rawMessage).toBe(body);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('falls back to a standard error when the body is not JSON', () => {
|
|
21
|
+
const error = ApiError.fromResponse(500, '<html>Internal blowup</html>');
|
|
22
|
+
|
|
23
|
+
expect(error.statusCode).toBe(500);
|
|
24
|
+
expect(error.code).toBe(500);
|
|
25
|
+
expect(error.errorName).toBe('InternalServerError');
|
|
26
|
+
expect(error.message).toContain('500');
|
|
27
|
+
expect(error.rawMessage).toBe('<html>Internal blowup</html>');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('falls back when the JSON does not match the wire shape', () => {
|
|
31
|
+
const error = ApiError.fromResponse(400, JSON.stringify({ message: 'wrong shape' }));
|
|
32
|
+
|
|
33
|
+
expect(error.errorName).toBe('BadRequest');
|
|
34
|
+
expect(error.code).toBe(400);
|
|
35
|
+
expect(error.statusCode).toBe(400);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('unknown status codes still normalize', () => {
|
|
39
|
+
const error = ApiError.fromResponse(418, 'teapot');
|
|
40
|
+
|
|
41
|
+
expect(error.errorName).toBe('UnknownError');
|
|
42
|
+
expect(error.statusCode).toBe(418);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('toJSON round-trips the frozen wire shape', () => {
|
|
46
|
+
const error = ApiError.fromResponse(
|
|
47
|
+
403,
|
|
48
|
+
JSON.stringify({ code: 4030, name: 'Forbidden', description: 'No access to this resource' }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(error.toJSON()).toEqual({
|
|
52
|
+
code: 4030,
|
|
53
|
+
name: 'Forbidden',
|
|
54
|
+
description: 'No access to this resource',
|
|
55
|
+
statusCode: 403,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { ApiError, HttpClient } from '../src/index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transport adapter tests (CONVENTIONS_TS.md §7 — the one place a fake `fetch` is allowed).
|
|
6
|
+
*
|
|
7
|
+
* Pins the no-body contract: a 2xx with an empty or non-JSON body must resolve to `undefined`,
|
|
8
|
+
* never throw a raw SyntaxError from response.json() — that would escape the ApiError contract
|
|
9
|
+
* (consumers expect either T or an ApiError, never a SyntaxError).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function fetchReturning(status: number, body: string, contentType?: string): typeof fetch {
|
|
13
|
+
const headers = new Headers();
|
|
14
|
+
if (contentType) headers.set('content-type', contentType);
|
|
15
|
+
return (async () => new Response(body.length ? body : null, { status, headers })) as unknown as typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('HttpClient — no-body success responses', () => {
|
|
19
|
+
test('204 resolves to undefined', async () => {
|
|
20
|
+
const http = new HttpClient({ baseURL: 'https://api.test', fetchFn: fetchReturning(204, '') });
|
|
21
|
+
await expect(http.request('DELETE', '/items/1')).resolves.toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('200 with an empty body resolves to undefined (no SyntaxError)', async () => {
|
|
25
|
+
const http = new HttpClient({ baseURL: 'https://api.test', fetchFn: fetchReturning(200, '') });
|
|
26
|
+
await expect(http.request('POST', '/items/1/touch')).resolves.toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('200 with a non-JSON content-type resolves to undefined', async () => {
|
|
30
|
+
const http = new HttpClient({
|
|
31
|
+
baseURL: 'https://api.test',
|
|
32
|
+
fetchFn: fetchReturning(200, 'OK', 'text/plain'),
|
|
33
|
+
});
|
|
34
|
+
await expect(http.request('GET', '/ping')).resolves.toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('200 with a JSON body still parses normally', async () => {
|
|
38
|
+
const http = new HttpClient({
|
|
39
|
+
baseURL: 'https://api.test',
|
|
40
|
+
fetchFn: fetchReturning(200, JSON.stringify({ id: 'x', name: 'First' }), 'application/json'),
|
|
41
|
+
});
|
|
42
|
+
await expect(http.request('GET', '/items/x')).resolves.toEqual({ id: 'x', name: 'First' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a JSON content-type with a malformed body surfaces a typed ApiError, not a SyntaxError', async () => {
|
|
46
|
+
const http = new HttpClient({
|
|
47
|
+
baseURL: 'https://api.test',
|
|
48
|
+
fetchFn: fetchReturning(200, '{ not json', 'application/json'),
|
|
49
|
+
});
|
|
50
|
+
await expect(http.request('GET', '/items/x')).rejects.toBeInstanceOf(ApiError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a non-2xx response still throws an ApiError', async () => {
|
|
54
|
+
const http = new HttpClient({
|
|
55
|
+
baseURL: 'https://api.test',
|
|
56
|
+
fetchFn: fetchReturning(409, JSON.stringify({ code: 4090, name: 'Conflict', description: 'exists' }), 'application/json'),
|
|
57
|
+
});
|
|
58
|
+
await expect(http.request('POST', '/items')).rejects.toMatchObject({ statusCode: 409, errorName: 'Conflict' });
|
|
59
|
+
});
|
|
60
|
+
});
|