ts-procedures 5.9.0 → 5.10.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/README.md +1 -1
- package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
- package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
- package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
- package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
- package/agent_config/claude-code/skills/review/SKILL.md +12 -17
- package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
- package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
- package/agent_config/lib/install-claude.mjs +22 -22
- package/docs/core.md +5 -9
- package/docs/streaming.md +9 -9
- package/package.json +3 -14
- package/src/client/call.test.ts +162 -0
- package/src/client/errors.test.ts +43 -0
- package/src/client/fetch-adapter.test.ts +340 -0
- package/src/client/hooks.test.ts +191 -0
- package/src/client/index.test.ts +290 -0
- package/src/client/request-builder.test.ts +184 -0
- package/src/client/stream.test.ts +331 -0
- package/src/codegen/bin/cli.test.ts +260 -0
- package/src/codegen/bin/cli.ts +282 -0
- package/src/codegen/constants.ts +1 -0
- package/src/codegen/e2e.test.ts +565 -0
- package/src/codegen/emit-client-runtime.test.ts +93 -0
- package/src/codegen/emit-client-runtime.ts +114 -0
- package/src/codegen/emit-client-types.test.ts +39 -0
- package/src/codegen/emit-client-types.ts +27 -0
- package/src/codegen/emit-errors.test.ts +202 -0
- package/src/codegen/emit-errors.ts +80 -0
- package/src/codegen/emit-index.test.ts +127 -0
- package/src/codegen/emit-index.ts +58 -0
- package/src/codegen/emit-scope.test.ts +624 -0
- package/src/codegen/emit-scope.ts +389 -0
- package/src/codegen/emit-types.test.ts +205 -0
- package/src/codegen/emit-types.ts +158 -0
- package/src/codegen/group-routes.test.ts +159 -0
- package/src/codegen/group-routes.ts +61 -0
- package/src/codegen/index.ts +30 -0
- package/src/codegen/naming.test.ts +50 -0
- package/src/codegen/naming.ts +25 -0
- package/src/codegen/pipeline.test.ts +316 -0
- package/src/codegen/pipeline.ts +108 -0
- package/src/codegen/resolve-envelope.test.ts +76 -0
- package/src/codegen/resolve-envelope.ts +61 -0
- package/src/errors.test.ts +163 -0
- package/src/errors.ts +107 -0
- package/src/exports.ts +7 -0
- package/src/implementations/http/doc-registry.test.ts +415 -0
- package/src/implementations/http/doc-registry.ts +143 -0
- package/src/implementations/http/express-rpc/README.md +6 -6
- package/src/implementations/http/express-rpc/index.test.ts +957 -0
- package/src/implementations/http/express-rpc/index.ts +266 -0
- package/src/implementations/http/express-rpc/types.ts +16 -0
- package/src/implementations/http/hono-api/index.test.ts +1341 -0
- package/src/implementations/http/hono-api/index.ts +463 -0
- package/src/implementations/http/hono-api/types.ts +16 -0
- package/src/implementations/http/hono-rpc/README.md +6 -6
- package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
- package/src/implementations/http/hono-rpc/index.ts +238 -0
- package/src/implementations/http/hono-rpc/types.ts +16 -0
- package/src/implementations/http/hono-stream/README.md +12 -12
- package/src/implementations/http/hono-stream/index.test.ts +1768 -0
- package/src/implementations/http/hono-stream/index.ts +456 -0
- package/src/implementations/http/hono-stream/types.ts +20 -0
- package/src/implementations/types.ts +174 -0
- package/src/index.test.ts +1185 -0
- package/src/index.ts +522 -0
- package/src/schema/compute-schema.test.ts +128 -0
- package/src/schema/compute-schema.ts +88 -0
- package/src/schema/extract-json-schema.test.ts +25 -0
- package/src/schema/extract-json-schema.ts +15 -0
- package/src/schema/parser.test.ts +182 -0
- package/src/schema/parser.ts +215 -0
- package/src/schema/resolve-schema-lib.test.ts +19 -0
- package/src/schema/resolve-schema-lib.ts +29 -0
- package/src/schema/types.ts +20 -0
- package/src/stack-utils.test.ts +94 -0
- package/src/stack-utils.ts +129 -0
- package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
- package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Client Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.client.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { createClient, createFetchAdapter } from 'ts-procedures/client'
|
|
7
|
+
import { createScopeBindings } from './generated/api'
|
|
8
|
+
// With --service-name: import { create{{Name}}Bindings } from './generated/api'
|
|
9
|
+
|
|
10
|
+
// Create the typed client
|
|
11
|
+
export const {{name}}Client = createClient({
|
|
12
|
+
adapter: createFetchAdapter(),
|
|
13
|
+
basePath: 'http://localhost:3000', // TODO: configure base URL
|
|
14
|
+
scopes: createScopeBindings,
|
|
15
|
+
// With --service-name: scopes: create{{Name}}Bindings,
|
|
16
|
+
hooks: {
|
|
17
|
+
onBeforeRequest(ctx) {
|
|
18
|
+
// TODO: add auth headers, request IDs, etc.
|
|
19
|
+
ctx.request.headers = {
|
|
20
|
+
...ctx.request.headers,
|
|
21
|
+
// Authorization: `Bearer ${getToken()}`,
|
|
22
|
+
}
|
|
23
|
+
return ctx
|
|
24
|
+
},
|
|
25
|
+
onAfterResponse(ctx) {
|
|
26
|
+
// TODO: handle global response concerns (401 redirect, rate limiting, etc.)
|
|
27
|
+
// if (ctx.response.status === 401) { redirect('/login') }
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// --- RPC call example ---
|
|
33
|
+
// const user = await {{name}}Client.users.GetUser({ userId: '123' })
|
|
34
|
+
|
|
35
|
+
// --- API call example (with schema.input channels) ---
|
|
36
|
+
// const user = await {{name}}Client.users.GetUser({ pathParams: { id: '123' } })
|
|
37
|
+
|
|
38
|
+
// --- Streaming call example ---
|
|
39
|
+
// const stream = {{name}}Client.events.WatchNotifications({ filter: 'all' })
|
|
40
|
+
// for await (const event of stream) {
|
|
41
|
+
// console.log(event) // Typed from server yieldType schema
|
|
42
|
+
// }
|
|
43
|
+
// const result = await stream.result // Typed from server returnType schema
|
|
44
|
+
|
|
45
|
+
// --- Per-procedure hook override ---
|
|
46
|
+
// const user = await {{name}}Client.users.GetUser({ id: '123' }, {
|
|
47
|
+
// onAfterResponse(ctx) {
|
|
48
|
+
// console.log('Rate limit:', ctx.response.headers['x-rate-limit-remaining'])
|
|
49
|
+
// },
|
|
50
|
+
// })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Test — `{{Name}}.client.test.ts`
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
57
|
+
import { {{name}}Client } from './{{Name}}.client'
|
|
58
|
+
|
|
59
|
+
// TODO: start your server before tests, or mock the adapter
|
|
60
|
+
// import { createServer } from './server'
|
|
61
|
+
// let server: ReturnType<typeof createServer>
|
|
62
|
+
|
|
63
|
+
describe('{{Name}} Client', () => {
|
|
64
|
+
// beforeAll(async () => {
|
|
65
|
+
// server = createServer()
|
|
66
|
+
// await server.listen(3000)
|
|
67
|
+
// })
|
|
68
|
+
//
|
|
69
|
+
// afterAll(async () => {
|
|
70
|
+
// await server.close()
|
|
71
|
+
// })
|
|
72
|
+
|
|
73
|
+
test('makes RPC calls with typed params and response', async () => {
|
|
74
|
+
// TODO: replace with an actual procedure call
|
|
75
|
+
// const result = await {{name}}Client.users.GetUser({ userId: 'test-id' })
|
|
76
|
+
// expect(result).toBeDefined()
|
|
77
|
+
// expect(result.id).toBe('test-id')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('handles validation errors from the server', async () => {
|
|
81
|
+
// TODO: call with invalid params and verify error handling
|
|
82
|
+
// await expect(
|
|
83
|
+
// {{name}}Client.users.GetUser({} as any)
|
|
84
|
+
// ).rejects.toThrow()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('streams data with typed yields', async () => {
|
|
88
|
+
// TODO: replace with an actual stream procedure call
|
|
89
|
+
// const stream = {{name}}Client.events.WatchNotifications({ filter: 'all' })
|
|
90
|
+
// const values = []
|
|
91
|
+
// for await (const event of stream) {
|
|
92
|
+
// values.push(event)
|
|
93
|
+
// if (values.length >= 3) break
|
|
94
|
+
// }
|
|
95
|
+
// expect(values).toHaveLength(3)
|
|
96
|
+
// const result = await stream.result
|
|
97
|
+
// expect(result).toBeDefined()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Code Generation Setup
|
|
103
|
+
|
|
104
|
+
Generate the typed client bindings from your running server:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Default (self-contained, namespaced types, JSDoc)
|
|
108
|
+
npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api
|
|
109
|
+
|
|
110
|
+
# With service name for multi-service apps
|
|
111
|
+
npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api --service-name {{Name}}
|
|
112
|
+
|
|
113
|
+
# Watch mode for development
|
|
114
|
+
npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api --watch
|
|
115
|
+
```
|
|
@@ -59,42 +59,42 @@ export function installClaude(projectRoot) {
|
|
|
59
59
|
|
|
60
60
|
// 1. Rules file — framework reference (always loaded in context)
|
|
61
61
|
const guideSkill = readFileSync(join(SKILLS_DIR, 'guide', 'SKILL.md'), 'utf-8');
|
|
62
|
+
const guideBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/guide';
|
|
62
63
|
const guideBody = stripFrontmatter(guideSkill)
|
|
63
|
-
//
|
|
64
|
-
.replace(/\
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
##
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
\`node_modules/ts-procedures/agent_config/claude-code/skills/guide/\`
|
|
72
|
-
|
|
73
|
-
- \`api-reference.md\` — Full API reference for Procedures, Create, CreateStream, errors, schema, HTTP implementations
|
|
74
|
-
- \`patterns.md\` — Prescribed patterns with code examples
|
|
75
|
-
- \`anti-patterns.md\` — Common mistakes to avoid with fixes
|
|
76
|
-
`;
|
|
77
|
-
|
|
78
|
-
writeFileSync(join(rulesDir, 'ts-procedures.md'), rulesContent, 'utf-8');
|
|
64
|
+
// Replace relative markdown links with node_modules paths
|
|
65
|
+
.replace(/\[patterns\.md\]\(patterns\.md\)/g, `\`${guideBasePath}/patterns.md\``)
|
|
66
|
+
.replace(/\[anti-patterns\.md\]\(anti-patterns\.md\)/g, `\`${guideBasePath}/anti-patterns.md\``)
|
|
67
|
+
.replace(/\[api-reference\.md\]\(api-reference\.md\)/g, `\`${guideBasePath}/api-reference.md\``)
|
|
68
|
+
// Replace the Workflow section — skill cross-references don't apply outside the plugin
|
|
69
|
+
.replace(/\n## Workflow[\s\S]*$/, '');
|
|
70
|
+
|
|
71
|
+
writeFileSync(join(rulesDir, 'ts-procedures.md'), autoHeader + guideBody, 'utf-8');
|
|
79
72
|
files.push('.claude/rules/ts-procedures.md');
|
|
80
73
|
|
|
81
74
|
// 2. Scaffold command
|
|
82
75
|
const scaffoldSkill = readFileSync(join(SKILLS_DIR, 'scaffold', 'SKILL.md'), 'utf-8');
|
|
76
|
+
const scaffoldBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/scaffold';
|
|
83
77
|
const scaffoldBody = stripFrontmatter(scaffoldSkill)
|
|
78
|
+
// Replace ${CLAUDE_SKILL_DIR} template path with node_modules path
|
|
84
79
|
.replace(
|
|
85
|
-
'
|
|
86
|
-
|
|
87
|
-
)
|
|
80
|
+
'`${CLAUDE_SKILL_DIR}/templates/$0.md`',
|
|
81
|
+
`\`${scaffoldBasePath}/templates/$0.md\``
|
|
82
|
+
)
|
|
83
|
+
// Replace Workflow section — skill cross-references don't apply outside the plugin
|
|
84
|
+
.replace(/\n## Workflow[\s\S]*$/, '');
|
|
88
85
|
|
|
89
86
|
writeFileSync(join(commandsDir, 'ts-procedures-scaffold.md'), autoHeader + scaffoldBody, 'utf-8');
|
|
90
87
|
files.push('.claude/commands/ts-procedures-scaffold.md');
|
|
91
88
|
|
|
92
89
|
// 3. Review command
|
|
93
90
|
const reviewSkill = readFileSync(join(SKILLS_DIR, 'review', 'SKILL.md'), 'utf-8');
|
|
91
|
+
const reviewBasePath = 'node_modules/ts-procedures/agent_config/claude-code/skills/review';
|
|
94
92
|
const reviewBody = stripFrontmatter(reviewSkill)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
// Replace relative markdown links with node_modules paths
|
|
94
|
+
.replace(/\[checklist\.md\]\(checklist\.md\)/g, `\`${reviewBasePath}/checklist.md\``)
|
|
95
|
+
.replace(
|
|
96
|
+
/\[anti-patterns\.md\]\(\.\.\/guide\/anti-patterns\.md\)/g,
|
|
97
|
+
`\`${guideBasePath}/anti-patterns.md\``
|
|
98
98
|
);
|
|
99
99
|
|
|
100
100
|
writeFileSync(join(commandsDir, 'ts-procedures-review.md'), autoHeader + reviewBody, 'utf-8');
|
package/docs/core.md
CHANGED
|
@@ -203,18 +203,15 @@ const { CreateUser } = Create(
|
|
|
203
203
|
|
|
204
204
|
## Schema Validation
|
|
205
205
|
|
|
206
|
-
ts-procedures
|
|
206
|
+
ts-procedures uses **TypeBox** for schema definitions:
|
|
207
207
|
|
|
208
208
|
```typescript
|
|
209
|
-
// TypeBox
|
|
210
209
|
import { Type } from 'typebox'
|
|
211
210
|
schema: { params: Type.Object({ title: Type.String() }) }
|
|
212
|
-
|
|
213
|
-
// Suretype
|
|
214
|
-
import { v } from 'suretype'
|
|
215
|
-
schema: { params: v.object({ title: v.string().required() }) }
|
|
216
211
|
```
|
|
217
212
|
|
|
213
|
+
TypeBox schemas are valid JSON Schema and work directly with AJV for runtime validation.
|
|
214
|
+
|
|
218
215
|
### Validation Behavior
|
|
219
216
|
|
|
220
217
|
AJV is configured with:
|
|
@@ -426,8 +423,8 @@ Defines a procedure.
|
|
|
426
423
|
**Parameters:**
|
|
427
424
|
- `name` - Unique procedure name (becomes named export)
|
|
428
425
|
- `config.description` - Optional description
|
|
429
|
-
- `config.schema.params` -
|
|
430
|
-
- `config.schema.returnType` -
|
|
426
|
+
- `config.schema.params` - TypeBox schema for params (validated at runtime)
|
|
427
|
+
- `config.schema.returnType` - TypeBox schema for return type (documentation only)
|
|
431
428
|
- Additional properties from `TExtendedConfig`
|
|
432
429
|
- `handler` - Async function `(ctx, params) => Promise<returnType>`
|
|
433
430
|
|
|
@@ -460,7 +457,6 @@ import {
|
|
|
460
457
|
extractJsonSchema,
|
|
461
458
|
schemaParser,
|
|
462
459
|
isTypeboxSchema,
|
|
463
|
-
isSuretypeSchema,
|
|
464
460
|
|
|
465
461
|
// Schema types
|
|
466
462
|
TJSONSchema,
|
package/docs/streaming.md
CHANGED
|
@@ -10,7 +10,7 @@ For the `CreateStream` function signature and config options, see [Core Procedur
|
|
|
10
10
|
|
|
11
11
|
```typescript
|
|
12
12
|
import { Procedures } from 'ts-procedures'
|
|
13
|
-
import {
|
|
13
|
+
import { Type } from 'typebox'
|
|
14
14
|
|
|
15
15
|
const { CreateStream } = Procedures<{ userId: string }>()
|
|
16
16
|
|
|
@@ -19,11 +19,11 @@ const { StreamUpdates } = CreateStream(
|
|
|
19
19
|
{
|
|
20
20
|
description: 'Stream real-time updates',
|
|
21
21
|
schema: {
|
|
22
|
-
params:
|
|
23
|
-
yieldType:
|
|
24
|
-
id:
|
|
25
|
-
message:
|
|
26
|
-
timestamp:
|
|
22
|
+
params: Type.Object({ topic: Type.String() }),
|
|
23
|
+
yieldType: Type.Object({
|
|
24
|
+
id: Type.String(),
|
|
25
|
+
message: Type.String(),
|
|
26
|
+
timestamp: Type.Number(),
|
|
27
27
|
}),
|
|
28
28
|
},
|
|
29
29
|
},
|
|
@@ -60,7 +60,7 @@ const { ValidatedStream } = CreateStream(
|
|
|
60
60
|
'ValidatedStream',
|
|
61
61
|
{
|
|
62
62
|
schema: {
|
|
63
|
-
yieldType:
|
|
63
|
+
yieldType: Type.Object({ count: Type.Number() }),
|
|
64
64
|
},
|
|
65
65
|
validateYields: true, // Enable runtime validation of each yield
|
|
66
66
|
},
|
|
@@ -148,8 +148,8 @@ CreateStream(
|
|
|
148
148
|
'LiveFeed',
|
|
149
149
|
{
|
|
150
150
|
schema: {
|
|
151
|
-
params:
|
|
152
|
-
yieldType:
|
|
151
|
+
params: Type.Object({ channel: Type.String() }),
|
|
152
|
+
yieldType: Type.Object({ event: Type.String(), data: Type.Any() }),
|
|
153
153
|
},
|
|
154
154
|
},
|
|
155
155
|
async function* (ctx, params) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-procedures",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
|
|
5
5
|
"main": "build/exports.js",
|
|
6
6
|
"types": "build/exports.d.ts",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"import": "./build/exports.js"
|
|
24
24
|
},
|
|
25
25
|
"./http": {
|
|
26
|
-
"types": "./build/implementations/
|
|
26
|
+
"types": "./build/implementations/types.d.ts"
|
|
27
27
|
},
|
|
28
28
|
"./express-rpc": {
|
|
29
29
|
"types": "./build/implementations/http/express-rpc/index.d.ts",
|
|
@@ -61,18 +61,7 @@
|
|
|
61
61
|
"build",
|
|
62
62
|
"docs",
|
|
63
63
|
"agent_config",
|
|
64
|
-
"src
|
|
65
|
-
"src/implementations/http/express-rpc/README.md",
|
|
66
|
-
"src/implementations/http/hono-rpc/README.md",
|
|
67
|
-
"src/implementations/http/hono-stream/README.md",
|
|
68
|
-
"src/client/types.ts",
|
|
69
|
-
"src/client/errors.ts",
|
|
70
|
-
"src/client/request-builder.ts",
|
|
71
|
-
"src/client/hooks.ts",
|
|
72
|
-
"src/client/call.ts",
|
|
73
|
-
"src/client/stream.ts",
|
|
74
|
-
"src/client/fetch-adapter.ts",
|
|
75
|
-
"src/client/index.ts"
|
|
64
|
+
"src"
|
|
76
65
|
],
|
|
77
66
|
"keywords": [
|
|
78
67
|
"typescript",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { executeCall } from './call.js'
|
|
3
|
+
import { ClientRequestError } from './errors.js'
|
|
4
|
+
import type {
|
|
5
|
+
ClientAdapter,
|
|
6
|
+
AdapterRequest,
|
|
7
|
+
AdapterResponse,
|
|
8
|
+
ClientHooks,
|
|
9
|
+
CallDescriptor,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// ── helpers ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function makeDescriptor(overrides?: Partial<CallDescriptor>): CallDescriptor {
|
|
15
|
+
return {
|
|
16
|
+
name: 'GetUser',
|
|
17
|
+
scope: 'users',
|
|
18
|
+
path: '/users',
|
|
19
|
+
method: 'GET',
|
|
20
|
+
kind: 'rpc',
|
|
21
|
+
params: { id: '42' },
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeAdapter(response?: Partial<AdapterResponse>): ClientAdapter {
|
|
27
|
+
return {
|
|
28
|
+
request: vi.fn(async (_req: AdapterRequest): Promise<AdapterResponse> => ({
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: {},
|
|
31
|
+
body: { id: '42', name: 'Alice' },
|
|
32
|
+
...response,
|
|
33
|
+
})),
|
|
34
|
+
stream: vi.fn(async () => {
|
|
35
|
+
throw new Error('stream not expected in call tests')
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── executeCall ───────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe('executeCall', () => {
|
|
43
|
+
it('calls adapter.request and returns body', async () => {
|
|
44
|
+
const adapter = makeAdapter({ body: { id: '1', name: 'Bob' } })
|
|
45
|
+
const result = await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
46
|
+
expect(adapter.request).toHaveBeenCalledOnce()
|
|
47
|
+
expect(result).toEqual({ id: '1', name: 'Bob' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('throws ClientRequestError on 4xx response', async () => {
|
|
51
|
+
const adapter = makeAdapter({ status: 404, body: { message: 'Not Found' } })
|
|
52
|
+
await expect(
|
|
53
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
54
|
+
).rejects.toThrow(ClientRequestError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('throws ClientRequestError on 5xx response', async () => {
|
|
58
|
+
const adapter = makeAdapter({ status: 500, body: { message: 'Server Error' } })
|
|
59
|
+
await expect(
|
|
60
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
61
|
+
).rejects.toThrow(ClientRequestError)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('throws ClientRequestError on 199 response (below 200)', async () => {
|
|
65
|
+
const adapter = makeAdapter({ status: 199, body: null })
|
|
66
|
+
await expect(
|
|
67
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
68
|
+
).rejects.toThrow(ClientRequestError)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does not throw on 2xx boundary responses (200, 201, 299)', async () => {
|
|
72
|
+
for (const status of [200, 201, 204, 299]) {
|
|
73
|
+
const adapter = makeAdapter({ status, body: null })
|
|
74
|
+
await expect(
|
|
75
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, undefined)
|
|
76
|
+
).resolves.not.toThrow()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('runs onBeforeRequest before calling adapter (headers are modified)', async () => {
|
|
81
|
+
const capturedHeaders: Record<string, string>[] = []
|
|
82
|
+
const adapter: ClientAdapter = {
|
|
83
|
+
request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
|
|
84
|
+
capturedHeaders.push(req.headers ?? {})
|
|
85
|
+
return { status: 200, headers: {}, body: {} }
|
|
86
|
+
}),
|
|
87
|
+
stream: vi.fn(async () => { throw new Error('not expected') }),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const globalHooks: ClientHooks = {
|
|
91
|
+
onBeforeRequest: (ctx) => ({
|
|
92
|
+
...ctx,
|
|
93
|
+
request: {
|
|
94
|
+
...ctx.request,
|
|
95
|
+
headers: { ...ctx.request.headers, 'x-auth': 'token-123' },
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
101
|
+
expect(capturedHeaders[0]?.['x-auth']).toBe('token-123')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('runs onAfterResponse after adapter returns', async () => {
|
|
105
|
+
const order: string[] = []
|
|
106
|
+
const adapter: ClientAdapter = {
|
|
107
|
+
request: vi.fn(async (): Promise<AdapterResponse> => {
|
|
108
|
+
order.push('adapter')
|
|
109
|
+
return { status: 200, headers: {}, body: {} }
|
|
110
|
+
}),
|
|
111
|
+
stream: vi.fn(async () => { throw new Error('not expected') }),
|
|
112
|
+
}
|
|
113
|
+
const globalHooks: ClientHooks = {
|
|
114
|
+
onAfterResponse: () => { order.push('afterResponse') },
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
118
|
+
expect(order).toEqual(['adapter', 'afterResponse'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not throw when onAfterResponse swallows non-2xx by mutating status', async () => {
|
|
122
|
+
const adapter = makeAdapter({ status: 401, body: { message: 'Unauthorized' } })
|
|
123
|
+
const globalHooks: ClientHooks = {
|
|
124
|
+
onAfterResponse: (ctx) => {
|
|
125
|
+
// Swallow the error by setting status to 200
|
|
126
|
+
ctx.response.status = 200
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await expect(
|
|
131
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
132
|
+
).resolves.not.toThrow()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('runs onError on adapter failure and re-throws', async () => {
|
|
136
|
+
const adapterError = new Error('Network failure')
|
|
137
|
+
const adapter: ClientAdapter = {
|
|
138
|
+
request: vi.fn(async () => { throw adapterError }),
|
|
139
|
+
stream: vi.fn(async () => { throw new Error('not expected') }),
|
|
140
|
+
}
|
|
141
|
+
const receivedErrors: unknown[] = []
|
|
142
|
+
const globalHooks: ClientHooks = {
|
|
143
|
+
onError: (ctx) => { receivedErrors.push(ctx.error) },
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
executeCall(makeDescriptor(), 'https://api.example.com', adapter, globalHooks, undefined)
|
|
148
|
+
).rejects.toThrow('Network failure')
|
|
149
|
+
expect(receivedErrors[0]).toBe(adapterError)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('passes per-procedure hooks as local hooks', async () => {
|
|
153
|
+
const adapter = makeAdapter()
|
|
154
|
+
const localOrder: string[] = []
|
|
155
|
+
const localHooks: ClientHooks = {
|
|
156
|
+
onBeforeRequest: (ctx) => { localOrder.push('local-before'); return ctx },
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await executeCall(makeDescriptor(), 'https://api.example.com', adapter, {}, localHooks)
|
|
160
|
+
expect(localOrder).toContain('local-before')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
describe('ClientRequestError', () => {
|
|
5
|
+
it('includes status, headers, and body', () => {
|
|
6
|
+
const err = new ClientRequestError({
|
|
7
|
+
status: 401,
|
|
8
|
+
headers: { 'x-request-id': 'abc' },
|
|
9
|
+
body: { message: 'Unauthorized' },
|
|
10
|
+
procedureName: 'GetUser',
|
|
11
|
+
scope: 'users',
|
|
12
|
+
})
|
|
13
|
+
expect(err).toBeInstanceOf(Error)
|
|
14
|
+
expect(err.name).toBe('ClientRequestError')
|
|
15
|
+
expect(err.status).toBe(401)
|
|
16
|
+
expect(err.headers['x-request-id']).toBe('abc')
|
|
17
|
+
expect(err.body).toEqual({ message: 'Unauthorized' })
|
|
18
|
+
expect(err.procedureName).toBe('GetUser')
|
|
19
|
+
expect(err.scope).toBe('users')
|
|
20
|
+
expect(err.message).toBe('GetUser (users) failed with status 401')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('ClientPathParamError', () => {
|
|
25
|
+
it('reports missing param', () => {
|
|
26
|
+
const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
|
|
27
|
+
expect(err).toBeInstanceOf(Error)
|
|
28
|
+
expect(err.name).toBe('ClientPathParamError')
|
|
29
|
+
expect(err.message).toContain('id')
|
|
30
|
+
expect(err.message).toContain('/users/:id')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('ClientStreamError', () => {
|
|
35
|
+
it('includes procedure context', () => {
|
|
36
|
+
const err = new ClientStreamError('stream interrupted', 'Watch', 'events')
|
|
37
|
+
expect(err).toBeInstanceOf(Error)
|
|
38
|
+
expect(err.name).toBe('ClientStreamError')
|
|
39
|
+
expect(err.procedureName).toBe('Watch')
|
|
40
|
+
expect(err.scope).toBe('events')
|
|
41
|
+
expect(err.message).toBe('stream interrupted')
|
|
42
|
+
})
|
|
43
|
+
})
|