lambder 1.0.125 → 1.0.126
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 +59 -0
- package/dist/Lambder.d.ts +12 -3
- package/dist/Lambder.js +2 -0
- package/dist/LambderApiContract.d.ts +42 -0
- package/dist/LambderApiContract.js +26 -0
- package/dist/LambderCaller.d.ts +15 -4
- package/dist/LambderCaller.js +2 -2
- package/dist/index.d.ts +1 -0
- package/docs/TYPE_SAFE_QUICK_START.md +123 -0
- package/examples/simplified-typed-api-example.ts +365 -0
- package/package.json +1 -1
- package/src/Lambder.ts +40 -1
- package/src/LambderApiContract.ts +51 -0
- package/src/LambderCaller.ts +40 -15
- package/src/index.ts +7 -0
- package/tests/type-safety.test.ts +224 -0
package/Readme.md
CHANGED
|
@@ -485,6 +485,65 @@ const loadPageData = async () => {
|
|
|
485
485
|
};
|
|
486
486
|
```
|
|
487
487
|
|
|
488
|
+
## Type-Safe APIs (Optional)
|
|
489
|
+
|
|
490
|
+
Want compile-time type checking for your APIs? It's incredibly simple!
|
|
491
|
+
|
|
492
|
+
### 1. Define Your API Contract
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
// shared/apiContract.ts
|
|
496
|
+
import type { ApiContract } from 'lambder';
|
|
497
|
+
|
|
498
|
+
export type MyApiContract = {
|
|
499
|
+
getUserById: { input: { userId: string }, output: User },
|
|
500
|
+
createUser: { input: CreateUserInput, output: User },
|
|
501
|
+
listUsers: { input: void, output: User[] }
|
|
502
|
+
} satisfies ApiContract;
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### 2. Backend - Pass Type to Constructor
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
import Lambder from 'lambder';
|
|
509
|
+
import type { MyApiContract } from './shared/apiContract';
|
|
510
|
+
|
|
511
|
+
const lambder = new Lambder<MyApiContract>({ publicPath: './public', apiPath: '/api' });
|
|
512
|
+
|
|
513
|
+
// Now addApi is type-safe!
|
|
514
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
515
|
+
// ctx.apiPayload is automatically typed as { userId: string } ✨
|
|
516
|
+
const user = await db.getUser(ctx.apiPayload.userId);
|
|
517
|
+
return resolver.api(user);
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### 3. Frontend - Pass Type to Constructor
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
import { LambderCaller } from 'lambder';
|
|
525
|
+
import type { MyApiContract } from './shared/apiContract';
|
|
526
|
+
|
|
527
|
+
const caller = new LambderCaller<MyApiContract>({ apiPath: '/api', isCorsEnabled: false });
|
|
528
|
+
|
|
529
|
+
// Now api() is type-safe with full autocomplete! ✨
|
|
530
|
+
const user = await caller.api('getUserById', { userId: '123' });
|
|
531
|
+
// ↑ IDE shows all available APIs
|
|
532
|
+
// ↑ Type-checked input
|
|
533
|
+
// user is typed as User | null | undefined
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### Benefits
|
|
537
|
+
|
|
538
|
+
✅ **Simple** - Just pass type to constructor, that's it!
|
|
539
|
+
✅ **Autocomplete** - IDE suggests available APIs as you type
|
|
540
|
+
✅ **Type Safety** - Inputs and outputs are fully typed
|
|
541
|
+
✅ **No Wrappers** - Use existing `api()` and `addApi()` methods
|
|
542
|
+
✅ **Opt-In** - Add when you want, skip when you don't
|
|
543
|
+
✅ **Zero Overhead** - Pure TypeScript types, no runtime code
|
|
544
|
+
|
|
545
|
+
📖 **[Read the Quick Start Guide](docs/TYPE_SAFE_QUICK_START.md)** for more details and examples!
|
|
546
|
+
|
|
488
547
|
## Contributing
|
|
489
548
|
|
|
490
549
|
Contributions are welcome! Especially for documentation. If you have an idea for an improvement or have found a bug, please open an issue or submit a pull request.
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import LambderResponseBuilder, { LambderResolverResponse } from "./LambderRespon
|
|
|
4
4
|
import LambderUtils from "./LambderUtils.js";
|
|
5
5
|
import { type LambderSessionContext } from "./LambderSessionManager.js";
|
|
6
6
|
import LambderSessionController from "./LambderSessionController.js";
|
|
7
|
+
import type { ApiContract } from "./LambderApiContract.js";
|
|
7
8
|
type Path = `/${string}`;
|
|
8
9
|
export type LambderRenderContext = {
|
|
9
10
|
host: string;
|
|
@@ -43,7 +44,7 @@ type GlobalErrorHandlerFunction = (err: Error, ctx: LambderRenderContext | null,
|
|
|
43
44
|
type RouteFallbackHandlerFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => LambderResolverResponse;
|
|
44
45
|
type ApiFallbackHandlerFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => LambderResolverResponse;
|
|
45
46
|
export declare const createContext: (event: APIGatewayProxyEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
|
|
46
|
-
export default class Lambder {
|
|
47
|
+
export default class Lambder<TContract extends ApiContract = any> {
|
|
47
48
|
apiPath: string;
|
|
48
49
|
apiVersion: null | string;
|
|
49
50
|
isCorsEnabled: boolean;
|
|
@@ -86,8 +87,16 @@ export default class Lambder {
|
|
|
86
87
|
}>): Promise<void>;
|
|
87
88
|
addRoute(condition: Path | ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
88
89
|
addSessionRoute(condition: Path | ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
89
|
-
addApi(apiName:
|
|
90
|
-
|
|
90
|
+
addApi(apiName: ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
91
|
+
addApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext & {
|
|
92
|
+
apiPayload: TContract[TApiName]['input'];
|
|
93
|
+
}, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
94
|
+
addApi(apiName: string, actionFn: ActionFunction): void;
|
|
95
|
+
addSessionApi(apiName: ConditionFunction | RegExp, actionFn: ActionFunction): void;
|
|
96
|
+
addSessionApi<TApiName extends keyof TContract & string>(apiName: TApiName, actionFn: (ctx: LambderRenderContext & {
|
|
97
|
+
apiPayload: TContract[TApiName]['input'];
|
|
98
|
+
}, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>): void;
|
|
99
|
+
addSessionApi(apiName: string, actionFn: ActionFunction): void;
|
|
91
100
|
addHook(hookEvent: 'created', hookFn: HookCreatedFunction, priority?: number): Promise<void>;
|
|
92
101
|
addHook(hookEvent: 'beforeRender', hookFn: HookBeforeRenderFunction, priority?: number): Promise<void>;
|
|
93
102
|
addHook(hookEvent: 'afterRender', hookFn: HookAfterRenderFunction, priority?: number): Promise<void>;
|
package/dist/Lambder.js
CHANGED
|
@@ -163,6 +163,7 @@ export default class Lambder {
|
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
165
|
;
|
|
166
|
+
// Implementation
|
|
166
167
|
addApi(apiName, actionFn) {
|
|
167
168
|
this.actionList.push({
|
|
168
169
|
conditionFn: (ctx) => (!!ctx.apiName && ((typeof apiName === "string" && ctx.apiName === apiName) ||
|
|
@@ -172,6 +173,7 @@ export default class Lambder {
|
|
|
172
173
|
});
|
|
173
174
|
}
|
|
174
175
|
;
|
|
176
|
+
// Implementation
|
|
175
177
|
addSessionApi(apiName, actionFn) {
|
|
176
178
|
this.actionList.push({
|
|
177
179
|
conditionFn: (ctx) => (!!ctx.apiName && ((typeof apiName === "string" && ctx.apiName === apiName) ||
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe API Contract System
|
|
3
|
+
*
|
|
4
|
+
* Define your API contract as a TypeScript type to get full type safety
|
|
5
|
+
* across frontend and backend with no runtime overhead.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
*
|
|
9
|
+
* export type MyApiContract = {
|
|
10
|
+
* getUserById: { input: { userId: string }, output: User },
|
|
11
|
+
* createUser: { input: CreateUserInput, output: User },
|
|
12
|
+
* listUsers: { input: void, output: User[] }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Frontend:
|
|
16
|
+
* const caller = new LambderCaller<MyApiContract>({ ... });
|
|
17
|
+
* const user = await caller.api('getUserById', { userId: '123' }); // typed!
|
|
18
|
+
*
|
|
19
|
+
* Backend:
|
|
20
|
+
* const lambder = new Lambder<MyApiContract>({ ... });
|
|
21
|
+
* lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
22
|
+
* // ctx.apiPayload is typed as { userId: string }
|
|
23
|
+
* return resolver.api(user); // user is typed as User
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Base type for API contracts
|
|
28
|
+
*/
|
|
29
|
+
export type ApiContract = {
|
|
30
|
+
[apiName: string]: {
|
|
31
|
+
input: any;
|
|
32
|
+
output: any;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Extract input type from contract for a specific API
|
|
37
|
+
*/
|
|
38
|
+
export type ApiInput<TContract extends ApiContract, TApiName extends keyof TContract> = TContract[TApiName]['input'];
|
|
39
|
+
/**
|
|
40
|
+
* Extract output type from contract for a specific API
|
|
41
|
+
*/
|
|
42
|
+
export type ApiOutput<TContract extends ApiContract, TApiName extends keyof TContract> = TContract[TApiName]['output'];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe API Contract System
|
|
3
|
+
*
|
|
4
|
+
* Define your API contract as a TypeScript type to get full type safety
|
|
5
|
+
* across frontend and backend with no runtime overhead.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
*
|
|
9
|
+
* export type MyApiContract = {
|
|
10
|
+
* getUserById: { input: { userId: string }, output: User },
|
|
11
|
+
* createUser: { input: CreateUserInput, output: User },
|
|
12
|
+
* listUsers: { input: void, output: User[] }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Frontend:
|
|
16
|
+
* const caller = new LambderCaller<MyApiContract>({ ... });
|
|
17
|
+
* const user = await caller.api('getUserById', { userId: '123' }); // typed!
|
|
18
|
+
*
|
|
19
|
+
* Backend:
|
|
20
|
+
* const lambder = new Lambder<MyApiContract>({ ... });
|
|
21
|
+
* lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
22
|
+
* // ctx.apiPayload is typed as { userId: string }
|
|
23
|
+
* return resolver.api(user); // user is typed as User
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
export {};
|
package/dist/LambderCaller.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LambderApiResponse } from './LambderResponseBuilder';
|
|
2
|
+
import type { ApiContract } from './LambderApiContract';
|
|
2
3
|
type VoidFunction = () => void | Promise<void>;
|
|
3
4
|
type FetchTracker = {
|
|
4
5
|
apiName: string;
|
|
@@ -21,7 +22,7 @@ type FetchEndEventHandler = (params: {
|
|
|
21
22
|
}) => void | Promise<void>;
|
|
22
23
|
type ErrorHandler = (err: Error) => void | Promise<void>;
|
|
23
24
|
type MessageHandler = (message: any) => void | Promise<void>;
|
|
24
|
-
export default class LambderCaller {
|
|
25
|
+
export default class LambderCaller<TContract extends ApiContract = any> {
|
|
25
26
|
private isCorsEnabled;
|
|
26
27
|
private apiPath;
|
|
27
28
|
private apiVersion?;
|
|
@@ -51,7 +52,7 @@ export default class LambderCaller {
|
|
|
51
52
|
fetchEndedHandler?: FetchEndEventHandler;
|
|
52
53
|
});
|
|
53
54
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
54
|
-
apiRaw<
|
|
55
|
+
apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: {
|
|
55
56
|
headers?: Record<string, any>;
|
|
56
57
|
versionExpiredHandler?: VoidFunction;
|
|
57
58
|
sessionExpiredHandler?: VoidFunction;
|
|
@@ -61,7 +62,17 @@ export default class LambderCaller {
|
|
|
61
62
|
errorHandler?: ErrorHandler;
|
|
62
63
|
fetchStartedHandler?: FetchStartEventHandler;
|
|
63
64
|
fetchEndedHandler?: FetchEndEventHandler;
|
|
64
|
-
}): Promise<LambderApiResponse<
|
|
65
|
-
api<
|
|
65
|
+
}): Promise<LambderApiResponse<TOutput> | null | undefined>;
|
|
66
|
+
api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: {
|
|
67
|
+
headers?: Record<string, any>;
|
|
68
|
+
versionExpiredHandler?: VoidFunction;
|
|
69
|
+
sessionExpiredHandler?: VoidFunction;
|
|
70
|
+
messageHandler?: MessageHandler;
|
|
71
|
+
errorMessageHandler?: MessageHandler;
|
|
72
|
+
notAuthorizedHandler?: VoidFunction;
|
|
73
|
+
errorHandler?: ErrorHandler;
|
|
74
|
+
fetchStartedHandler?: FetchStartEventHandler;
|
|
75
|
+
fetchEndedHandler?: FetchEndEventHandler;
|
|
76
|
+
}): Promise<TOutput | null | undefined>;
|
|
66
77
|
}
|
|
67
78
|
export {};
|
package/dist/LambderCaller.js
CHANGED
|
@@ -131,8 +131,8 @@ export default class LambderCaller {
|
|
|
131
131
|
}
|
|
132
132
|
;
|
|
133
133
|
// Use the same type for api but adjust the return type
|
|
134
|
-
async api(
|
|
135
|
-
const result = await this.apiRaw(
|
|
134
|
+
async api(apiName, payload, options) {
|
|
135
|
+
const result = await this.apiRaw(apiName, payload, options);
|
|
136
136
|
return result?.payload;
|
|
137
137
|
}
|
|
138
138
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { default as LambderCaller } from "./LambderCaller.js";
|
|
|
4
4
|
export { default as LambderResponseBuilder } from "./LambderResponseBuilder.js";
|
|
5
5
|
export { default as LambderResolver } from "./LambderResolver.js";
|
|
6
6
|
export { default as LambderSessionManager } from "./LambderSessionManager.js";
|
|
7
|
+
export { type ApiContract, type ApiInput, type ApiOutput, } from "./LambderApiContract.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Type-Safe API Quick Start
|
|
2
|
+
|
|
3
|
+
## In 3 Simple Steps
|
|
4
|
+
|
|
5
|
+
### 1. Define Your Contract Type
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// shared/apiContract.ts
|
|
9
|
+
import type { ApiContract } from 'lambder';
|
|
10
|
+
|
|
11
|
+
export type MyApiContract = {
|
|
12
|
+
getUserById: { input: { userId: string }, output: User },
|
|
13
|
+
createUser: { input: CreateUserInput, output: User },
|
|
14
|
+
listUsers: { input: void, output: User[] }
|
|
15
|
+
} satisfies ApiContract;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 2. Backend - Pass Type to Lambder
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import Lambder from 'lambder';
|
|
22
|
+
import type { MyApiContract } from './shared/apiContract';
|
|
23
|
+
|
|
24
|
+
const lambder = new Lambder<MyApiContract>({
|
|
25
|
+
publicPath: './public',
|
|
26
|
+
apiPath: '/api'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Now addApi is type-safe!
|
|
30
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
31
|
+
// ctx.apiPayload is automatically typed as { userId: string }
|
|
32
|
+
const user = await db.getUser(ctx.apiPayload.userId);
|
|
33
|
+
return resolver.api(user);
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 3. Frontend - Pass Type to LambderCaller
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { LambderCaller } from 'lambder';
|
|
41
|
+
import type { MyApiContract } from './shared/apiContract';
|
|
42
|
+
|
|
43
|
+
const caller = new LambderCaller<MyApiContract>({
|
|
44
|
+
apiPath: '/api',
|
|
45
|
+
isCorsEnabled: false
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Now api() is type-safe!
|
|
49
|
+
const user = await caller.api('getUserById', { userId: '123' });
|
|
50
|
+
// user is typed as User | null | undefined
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## That's It!
|
|
54
|
+
|
|
55
|
+
- ✅ **No wrapper functions needed**
|
|
56
|
+
- ✅ **Use existing `api()` and `addApi()` methods**
|
|
57
|
+
- ✅ **Full autocomplete in IDE**
|
|
58
|
+
- ✅ **Type-safe inputs and outputs**
|
|
59
|
+
- ✅ **Backward compatible**
|
|
60
|
+
|
|
61
|
+
## Contract Type Format
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
type MyApiContract = {
|
|
65
|
+
apiName: { input: InputType, output: OutputType }
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Examples
|
|
70
|
+
|
|
71
|
+
### API with parameters
|
|
72
|
+
```typescript
|
|
73
|
+
getUserById: { input: { userId: string }, output: User }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### API with no input
|
|
77
|
+
```typescript
|
|
78
|
+
listAll: { input: void, output: Item[] }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### API with complex types
|
|
82
|
+
```typescript
|
|
83
|
+
updateUser: {
|
|
84
|
+
input: { id: string } & Partial<User>,
|
|
85
|
+
output: User
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### API with conditional output
|
|
90
|
+
```typescript
|
|
91
|
+
login: {
|
|
92
|
+
input: { email: string, password: string },
|
|
93
|
+
output: { success: boolean, user?: User, error?: string }
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Without Type Safety (Still Works!)
|
|
98
|
+
|
|
99
|
+
Don't want type safety? Just don't pass the generic type:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Frontend
|
|
103
|
+
const caller = new LambderCaller({ ... }); // No generic
|
|
104
|
+
await caller.api('anyApi', { anything: true }); // Works, but untyped
|
|
105
|
+
|
|
106
|
+
// Backend
|
|
107
|
+
const lambder = new Lambder({ ... }); // No generic
|
|
108
|
+
lambder.addApi('anyApi', async (ctx, resolver) => {
|
|
109
|
+
// ctx.apiPayload is any
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Full Example
|
|
114
|
+
|
|
115
|
+
See [simplified-typed-api-example.ts](../examples/simplified-typed-api-example.ts) for a complete working example.
|
|
116
|
+
|
|
117
|
+
## Key Points
|
|
118
|
+
|
|
119
|
+
- **Contract is just a TypeScript type** - No runtime code!
|
|
120
|
+
- **Zero overhead** - All type checking happens at compile time
|
|
121
|
+
- **Opt-in** - Use types when you want them
|
|
122
|
+
- **Simple** - Just pass type to constructor
|
|
123
|
+
- **Autocomplete** - IDE shows available APIs as you type
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified Type-Safe API Example
|
|
3
|
+
*
|
|
4
|
+
* This example shows how to use Lambder's opt-in type-safe API system.
|
|
5
|
+
* Simply pass your API contract type to LambderCaller and Lambder constructors,
|
|
6
|
+
* and get full type safety with no extra wrapper functions needed!
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Lambder from '../src/Lambder.js';
|
|
10
|
+
import LambderCaller from '../src/LambderCaller.js';
|
|
11
|
+
import type { ApiContract } from '../src/index.js';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Step 1: Define your data types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
type User = {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
role: 'admin' | 'user';
|
|
22
|
+
createdAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CreateUserInput = {
|
|
26
|
+
name: string;
|
|
27
|
+
email: string;
|
|
28
|
+
password: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type UpdateUserInput = {
|
|
32
|
+
userId: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
email?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type LoginInput = {
|
|
38
|
+
email: string;
|
|
39
|
+
password: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type LoginOutput = {
|
|
43
|
+
success: boolean;
|
|
44
|
+
user?: User;
|
|
45
|
+
token?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Step 2: Define your API contract (shared between frontend and backend)
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
export type MyApiContract = {
|
|
54
|
+
// API with input and output
|
|
55
|
+
getUserById: { input: { userId: string }, output: User },
|
|
56
|
+
|
|
57
|
+
// API with complex input/output
|
|
58
|
+
createUser: { input: CreateUserInput, output: User },
|
|
59
|
+
updateUser: { input: UpdateUserInput, output: User },
|
|
60
|
+
|
|
61
|
+
// API with void input (no parameters needed)
|
|
62
|
+
listUsers: { input: void, output: User[] },
|
|
63
|
+
getCurrentUser: { input: void, output: User },
|
|
64
|
+
|
|
65
|
+
// API with conditional output
|
|
66
|
+
login: { input: LoginInput, output: LoginOutput },
|
|
67
|
+
|
|
68
|
+
// API with primitive output
|
|
69
|
+
getUserCount: { input: void, output: number },
|
|
70
|
+
deleteUser: { input: { userId: string }, output: boolean },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Step 3: Backend - Pass contract type to Lambder
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
export function setupBackend() {
|
|
78
|
+
// Pass the contract type as a generic parameter
|
|
79
|
+
const lambder = new Lambder<MyApiContract>({
|
|
80
|
+
publicPath: './public',
|
|
81
|
+
apiPath: '/api',
|
|
82
|
+
apiVersion: '1.0.0',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Now addApi is type-safe! ctx.apiPayload is automatically typed!
|
|
86
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
87
|
+
// ctx.apiPayload is typed as { userId: string }
|
|
88
|
+
const userId = ctx.apiPayload.userId; // ✅ TypeScript knows this!
|
|
89
|
+
|
|
90
|
+
// Mock database call
|
|
91
|
+
const user: User = {
|
|
92
|
+
id: userId,
|
|
93
|
+
name: 'John Doe',
|
|
94
|
+
email: 'john@example.com',
|
|
95
|
+
role: 'user',
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return resolver.api(user);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Session API with typed payload
|
|
103
|
+
lambder.addSessionApi('createUser', async (ctx, resolver) => {
|
|
104
|
+
// ctx.apiPayload is typed as CreateUserInput
|
|
105
|
+
const { name, email, password } = ctx.apiPayload;
|
|
106
|
+
|
|
107
|
+
// Validation with type safety
|
|
108
|
+
if (!name || !email || !password) {
|
|
109
|
+
return resolver.api(null, {
|
|
110
|
+
errorMessage: 'Missing required fields'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Create user
|
|
115
|
+
const newUser: User = {
|
|
116
|
+
id: Math.random().toString(36).substr(2, 9),
|
|
117
|
+
name,
|
|
118
|
+
email,
|
|
119
|
+
role: 'user',
|
|
120
|
+
createdAt: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return resolver.api(newUser);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// API with void input
|
|
127
|
+
lambder.addApi('listUsers', async (ctx, resolver) => {
|
|
128
|
+
// ctx.apiPayload is void/undefined
|
|
129
|
+
const users: User[] = [
|
|
130
|
+
{ id: '1', name: 'John', email: 'john@example.com', role: 'user', createdAt: new Date().toISOString() },
|
|
131
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com', role: 'admin', createdAt: new Date().toISOString() },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
return resolver.api(users);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Complex API with conditional response
|
|
138
|
+
lambder.addApi('login', async (ctx, resolver) => {
|
|
139
|
+
// ctx.apiPayload is typed as LoginInput
|
|
140
|
+
const { email, password } = ctx.apiPayload;
|
|
141
|
+
|
|
142
|
+
// Mock authentication
|
|
143
|
+
if (email === 'test@example.com' && password === 'password123') {
|
|
144
|
+
const result: LoginOutput = {
|
|
145
|
+
success: true,
|
|
146
|
+
user: {
|
|
147
|
+
id: '1',
|
|
148
|
+
name: 'Test User',
|
|
149
|
+
email: email,
|
|
150
|
+
role: 'user',
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
},
|
|
153
|
+
token: 'mock-jwt-token',
|
|
154
|
+
};
|
|
155
|
+
return resolver.api(result);
|
|
156
|
+
} else {
|
|
157
|
+
const result: LoginOutput = {
|
|
158
|
+
success: false,
|
|
159
|
+
error: 'Invalid credentials',
|
|
160
|
+
};
|
|
161
|
+
return resolver.api(result);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// You can still use RegExp or functions for dynamic patterns (untyped)
|
|
166
|
+
lambder.addApi(/^admin\./, async (ctx, resolver) => {
|
|
167
|
+
// ctx.apiPayload is any (untyped)
|
|
168
|
+
return resolver.api({ message: 'Admin API' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return lambder;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Step 4: Frontend - Pass contract type to LambderCaller
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
export function setupFrontend() {
|
|
179
|
+
// Pass the contract type as a generic parameter
|
|
180
|
+
const caller = new LambderCaller<MyApiContract>({
|
|
181
|
+
apiPath: '/api',
|
|
182
|
+
apiVersion: '1.0.0',
|
|
183
|
+
isCorsEnabled: false,
|
|
184
|
+
errorHandler: (err) => {
|
|
185
|
+
console.error('API Error:', err);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Now all API calls are type-safe!
|
|
190
|
+
|
|
191
|
+
// Example 1: Get user by ID
|
|
192
|
+
async function example1() {
|
|
193
|
+
// TypeScript knows:
|
|
194
|
+
// - First parameter is 'getUserById' (autocomplete shows all API names!)
|
|
195
|
+
// - Second parameter must be { userId: string }
|
|
196
|
+
// - Return type is User | null | undefined
|
|
197
|
+
const user = await caller.api('getUserById', { userId: '123' });
|
|
198
|
+
|
|
199
|
+
if (user) {
|
|
200
|
+
console.log(user.name); // ✅ TypeScript knows 'name' exists
|
|
201
|
+
console.log(user.email); // ✅ TypeScript knows 'email' exists
|
|
202
|
+
console.log(user.role); // ✅ TypeScript knows 'role' is 'admin' | 'user'
|
|
203
|
+
// console.log(user.age); // ✗ Error: Property 'age' does not exist
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Example 2: Create user
|
|
208
|
+
async function example2() {
|
|
209
|
+
// TypeScript enforces the CreateUserInput type
|
|
210
|
+
const newUser = await caller.api('createUser', {
|
|
211
|
+
name: 'Alice',
|
|
212
|
+
email: 'alice@example.com',
|
|
213
|
+
password: 'secret123',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (newUser) {
|
|
217
|
+
console.log('Created user:', newUser.id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// This would be a TypeScript error:
|
|
221
|
+
// await caller.api('createUser', { name: 'Bob' }); // ✗ Missing email and password
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Example 3: Login
|
|
225
|
+
async function example3() {
|
|
226
|
+
const result = await caller.api('login', {
|
|
227
|
+
email: 'test@example.com',
|
|
228
|
+
password: 'password123',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (result?.success && result.user) {
|
|
232
|
+
console.log('Logged in as:', result.user.name);
|
|
233
|
+
console.log('Token:', result.token);
|
|
234
|
+
} else {
|
|
235
|
+
console.error('Login failed:', result?.error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Example 4: List users (void input)
|
|
240
|
+
async function example4() {
|
|
241
|
+
// For void input, pass undefined
|
|
242
|
+
const users = await caller.api('listUsers', undefined);
|
|
243
|
+
|
|
244
|
+
if (users) {
|
|
245
|
+
users.forEach(user => {
|
|
246
|
+
console.log(user.name); // ✅ TypeScript knows the array type
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Example 5: Get user count (primitive output)
|
|
252
|
+
async function example5() {
|
|
253
|
+
const count = await caller.api('getUserCount', undefined);
|
|
254
|
+
if (count !== null && count !== undefined) {
|
|
255
|
+
console.log(`Total users: ${count}`); // count is number
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Example 6: With custom headers
|
|
260
|
+
async function example6() {
|
|
261
|
+
const user = await caller.api(
|
|
262
|
+
'getUserById',
|
|
263
|
+
{ userId: '456' },
|
|
264
|
+
{ headers: { 'X-Custom-Header': 'value' } }
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Example 7: Using apiRaw for full response
|
|
269
|
+
async function example7() {
|
|
270
|
+
const response = await caller.apiRaw('getUserById', { userId: '123' });
|
|
271
|
+
|
|
272
|
+
if (response) {
|
|
273
|
+
console.log('Payload:', response.payload); // User | null
|
|
274
|
+
if (response.logList) {
|
|
275
|
+
console.log('Logs:', response.logList);
|
|
276
|
+
}
|
|
277
|
+
if (response.errorMessage) {
|
|
278
|
+
console.error('Error:', response.errorMessage);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return caller;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ============================================================================
|
|
287
|
+
// Without Contract (Backward Compatibility)
|
|
288
|
+
// ============================================================================
|
|
289
|
+
|
|
290
|
+
export function setupWithoutContract() {
|
|
291
|
+
// If you don't pass a contract type, it works like before (untyped)
|
|
292
|
+
const caller = new LambderCaller({
|
|
293
|
+
apiPath: '/api',
|
|
294
|
+
isCorsEnabled: false,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Still works, but no type safety
|
|
298
|
+
async function untypedExample() {
|
|
299
|
+
const user = await caller.api('getUserById', { userId: '123' });
|
|
300
|
+
// user is any
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const lambder = new Lambder({
|
|
304
|
+
publicPath: './public',
|
|
305
|
+
apiPath: '/api',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Still works, but no type safety
|
|
309
|
+
lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
310
|
+
// ctx.apiPayload is any
|
|
311
|
+
const user = { id: ctx.apiPayload.userId, name: 'User' };
|
|
312
|
+
return resolver.api(user);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ============================================================================
|
|
317
|
+
// Type Safety Examples
|
|
318
|
+
// ============================================================================
|
|
319
|
+
|
|
320
|
+
export function typeSafetyExamples() {
|
|
321
|
+
const caller = new LambderCaller<MyApiContract>({ apiPath: '/api', isCorsEnabled: false });
|
|
322
|
+
|
|
323
|
+
async function examples() {
|
|
324
|
+
// ✓ VALID:
|
|
325
|
+
await caller.api('getUserById', { userId: '123' });
|
|
326
|
+
await caller.api('createUser', { name: 'Alice', email: 'alice@example.com', password: 'pass' });
|
|
327
|
+
await caller.api('listUsers', undefined);
|
|
328
|
+
|
|
329
|
+
// ✗ ERRORS (TypeScript prevents):
|
|
330
|
+
// await caller.api('getUserById'); // Missing required payload
|
|
331
|
+
// await caller.api('getUserById', { id: '123' }); // Wrong property name (should be userId)
|
|
332
|
+
// await caller.api('createUser', { name: 'Bob' }); // Missing email and password
|
|
333
|
+
// await caller.api('nonExistentApi', {}); // API doesn't exist in contract
|
|
334
|
+
|
|
335
|
+
// Type inference works:
|
|
336
|
+
const user = await caller.api('getUserById', { userId: '123' });
|
|
337
|
+
if (user) {
|
|
338
|
+
console.log(user.name); // ✓ TypeScript knows user has name
|
|
339
|
+
// console.log(user.age); // ✗ Error: Property 'age' does not exist
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const users = await caller.api('listUsers', undefined);
|
|
343
|
+
if (users) {
|
|
344
|
+
users.forEach(u => {
|
|
345
|
+
console.log(u.email); // ✓ TypeScript knows array item structure
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============================================================================
|
|
352
|
+
// Key Benefits
|
|
353
|
+
// ============================================================================
|
|
354
|
+
//
|
|
355
|
+
// 1. ✅ Type Safety - Frontend and backend share the same types
|
|
356
|
+
// 2. ✅ Autocomplete - IDE suggests available APIs as you type
|
|
357
|
+
// 3. ✅ No Wrappers - Use existing api() and addApi() methods
|
|
358
|
+
// 4. ✅ Opt-In - Add types when you want, or don't use them at all
|
|
359
|
+
// 5. ✅ Backward Compatible - Existing code works without changes
|
|
360
|
+
// 6. ✅ Simple - Just pass type to constructor, that's it!
|
|
361
|
+
// 7. ✅ Zero Runtime Overhead - Pure TypeScript types
|
|
362
|
+
//
|
|
363
|
+
// ============================================================================
|
|
364
|
+
|
|
365
|
+
export { Lambder, LambderCaller, type ApiContract };
|
package/package.json
CHANGED
package/src/Lambder.ts
CHANGED
|
@@ -8,6 +8,7 @@ import LambderResponseBuilder, { LambderResolverResponse } from "./LambderRespon
|
|
|
8
8
|
import LambderUtils from "./LambderUtils.js";
|
|
9
9
|
import LambderSessionManager, { type LambderSessionContext } from "./LambderSessionManager.js";
|
|
10
10
|
import LambderSessionController from "./LambderSessionController.js";
|
|
11
|
+
import type { ApiContract } from "./LambderApiContract.js";
|
|
11
12
|
|
|
12
13
|
type Path = `/${string}`;
|
|
13
14
|
|
|
@@ -92,7 +93,7 @@ export const createContext = (
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
export default class Lambder {
|
|
96
|
+
export default class Lambder<TContract extends ApiContract = any> {
|
|
96
97
|
public apiPath: string;
|
|
97
98
|
public apiVersion: null|string;
|
|
98
99
|
public isCorsEnabled: boolean = false;
|
|
@@ -238,6 +239,25 @@ export default class Lambder {
|
|
|
238
239
|
});
|
|
239
240
|
};
|
|
240
241
|
|
|
242
|
+
// Overload for untyped API with RegExp or function
|
|
243
|
+
addApi(
|
|
244
|
+
apiName: ConditionFunction|RegExp,
|
|
245
|
+
actionFn: ActionFunction
|
|
246
|
+
):void;
|
|
247
|
+
// Overload for typed API with string name (must come before untyped string overload)
|
|
248
|
+
addApi<TApiName extends keyof TContract & string>(
|
|
249
|
+
apiName: TApiName,
|
|
250
|
+
actionFn: (
|
|
251
|
+
ctx: LambderRenderContext & { apiPayload: TContract[TApiName]['input'] },
|
|
252
|
+
resolver: LambderResolver
|
|
253
|
+
) => LambderResolverResponse|Promise<LambderResolverResponse>
|
|
254
|
+
):void;
|
|
255
|
+
// Overload for untyped API with string (backward compatibility, must be last)
|
|
256
|
+
addApi(
|
|
257
|
+
apiName: string,
|
|
258
|
+
actionFn: ActionFunction
|
|
259
|
+
):void;
|
|
260
|
+
// Implementation
|
|
241
261
|
addApi(apiName: string|ConditionFunction|RegExp, actionFn: ActionFunction):void{
|
|
242
262
|
this.actionList.push({
|
|
243
263
|
conditionFn: (ctx:LambderRenderContext) => (
|
|
@@ -251,6 +271,25 @@ export default class Lambder {
|
|
|
251
271
|
});
|
|
252
272
|
};
|
|
253
273
|
|
|
274
|
+
// Overload for untyped session API with RegExp or function
|
|
275
|
+
addSessionApi(
|
|
276
|
+
apiName: ConditionFunction|RegExp,
|
|
277
|
+
actionFn: ActionFunction
|
|
278
|
+
):void;
|
|
279
|
+
// Overload for typed session API with string name (must come before untyped string overload)
|
|
280
|
+
addSessionApi<TApiName extends keyof TContract & string>(
|
|
281
|
+
apiName: TApiName,
|
|
282
|
+
actionFn: (
|
|
283
|
+
ctx: LambderRenderContext & { apiPayload: TContract[TApiName]['input'] },
|
|
284
|
+
resolver: LambderResolver
|
|
285
|
+
) => LambderResolverResponse|Promise<LambderResolverResponse>
|
|
286
|
+
):void;
|
|
287
|
+
// Overload for untyped session API with string (backward compatibility, must be last)
|
|
288
|
+
addSessionApi(
|
|
289
|
+
apiName: string,
|
|
290
|
+
actionFn: ActionFunction
|
|
291
|
+
):void;
|
|
292
|
+
// Implementation
|
|
254
293
|
addSessionApi(apiName: string|ConditionFunction|RegExp, actionFn: ActionFunction):void{
|
|
255
294
|
this.actionList.push({
|
|
256
295
|
conditionFn: (ctx:LambderRenderContext) => (
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe API Contract System
|
|
3
|
+
*
|
|
4
|
+
* Define your API contract as a TypeScript type to get full type safety
|
|
5
|
+
* across frontend and backend with no runtime overhead.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
*
|
|
9
|
+
* export type MyApiContract = {
|
|
10
|
+
* getUserById: { input: { userId: string }, output: User },
|
|
11
|
+
* createUser: { input: CreateUserInput, output: User },
|
|
12
|
+
* listUsers: { input: void, output: User[] }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Frontend:
|
|
16
|
+
* const caller = new LambderCaller<MyApiContract>({ ... });
|
|
17
|
+
* const user = await caller.api('getUserById', { userId: '123' }); // typed!
|
|
18
|
+
*
|
|
19
|
+
* Backend:
|
|
20
|
+
* const lambder = new Lambder<MyApiContract>({ ... });
|
|
21
|
+
* lambder.addApi('getUserById', async (ctx, resolver) => {
|
|
22
|
+
* // ctx.apiPayload is typed as { userId: string }
|
|
23
|
+
* return resolver.api(user); // user is typed as User
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base type for API contracts
|
|
29
|
+
*/
|
|
30
|
+
export type ApiContract = {
|
|
31
|
+
[apiName: string]: {
|
|
32
|
+
input: any;
|
|
33
|
+
output: any;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract input type from contract for a specific API
|
|
39
|
+
*/
|
|
40
|
+
export type ApiInput<
|
|
41
|
+
TContract extends ApiContract,
|
|
42
|
+
TApiName extends keyof TContract
|
|
43
|
+
> = TContract[TApiName]['input'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract output type from contract for a specific API
|
|
47
|
+
*/
|
|
48
|
+
export type ApiOutput<
|
|
49
|
+
TContract extends ApiContract,
|
|
50
|
+
TApiName extends keyof TContract
|
|
51
|
+
> = TContract[TApiName]['output'];
|
package/src/LambderCaller.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Cookies from 'js-cookie';
|
|
2
2
|
import { LambderApiResponse } from './LambderResponseBuilder';
|
|
3
|
+
import type { ApiContract } from './LambderApiContract';
|
|
3
4
|
|
|
4
5
|
type VoidFunction = ()=>void|Promise<void>;
|
|
5
6
|
type FetchTracker = { apiName: string, done: boolean, fetchEndCalled: boolean };
|
|
@@ -23,7 +24,7 @@ type FetchEndEventHandler = (params: {
|
|
|
23
24
|
type ErrorHandler = (err: Error) => void|Promise<void>;
|
|
24
25
|
type MessageHandler = (message:any) => void|Promise<void>;
|
|
25
26
|
|
|
26
|
-
export default class LambderCaller {
|
|
27
|
+
export default class LambderCaller<TContract extends ApiContract = any> {
|
|
27
28
|
private isCorsEnabled: boolean;
|
|
28
29
|
private apiPath: string;
|
|
29
30
|
private apiVersion?: string;
|
|
@@ -90,17 +91,24 @@ export default class LambderCaller {
|
|
|
90
91
|
this.sessionCsrfCookieKey = sessionCsrfCookieKey;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
async apiRaw<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
async apiRaw<
|
|
95
|
+
TApiName extends keyof TContract & string = string,
|
|
96
|
+
TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any
|
|
97
|
+
>(
|
|
98
|
+
apiName: TApiName,
|
|
99
|
+
payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any,
|
|
100
|
+
options?: {
|
|
101
|
+
headers?: Record<string, any>
|
|
102
|
+
versionExpiredHandler?: VoidFunction,
|
|
103
|
+
sessionExpiredHandler?: VoidFunction,
|
|
104
|
+
messageHandler?: MessageHandler,
|
|
105
|
+
errorMessageHandler?: MessageHandler,
|
|
106
|
+
notAuthorizedHandler?: VoidFunction,
|
|
107
|
+
errorHandler?: ErrorHandler,
|
|
108
|
+
fetchStartedHandler?: FetchStartEventHandler,
|
|
109
|
+
fetchEndedHandler?: FetchEndEventHandler,
|
|
110
|
+
}
|
|
111
|
+
): Promise<LambderApiResponse<TOutput>|null|undefined>{
|
|
104
112
|
const headers = options?.headers;
|
|
105
113
|
const fetchTracker: FetchTracker = { apiName, done: false, fetchEndCalled: false };
|
|
106
114
|
try {
|
|
@@ -125,7 +133,7 @@ export default class LambderCaller {
|
|
|
125
133
|
}else{
|
|
126
134
|
return res.json();
|
|
127
135
|
}
|
|
128
|
-
}) as LambderApiResponse<
|
|
136
|
+
}) as LambderApiResponse<TOutput>;
|
|
129
137
|
fetchTracker.done = true;
|
|
130
138
|
if(this.fetchEndedHandler){
|
|
131
139
|
fetchTracker.fetchEndCalled = true;
|
|
@@ -189,8 +197,25 @@ export default class LambderCaller {
|
|
|
189
197
|
};
|
|
190
198
|
|
|
191
199
|
// Use the same type for api but adjust the return type
|
|
192
|
-
async api<
|
|
193
|
-
|
|
200
|
+
async api<
|
|
201
|
+
TApiName extends keyof TContract & string = string,
|
|
202
|
+
TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any
|
|
203
|
+
>(
|
|
204
|
+
apiName: TApiName,
|
|
205
|
+
payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any,
|
|
206
|
+
options?: {
|
|
207
|
+
headers?: Record<string, any>
|
|
208
|
+
versionExpiredHandler?: VoidFunction,
|
|
209
|
+
sessionExpiredHandler?: VoidFunction,
|
|
210
|
+
messageHandler?: MessageHandler,
|
|
211
|
+
errorMessageHandler?: MessageHandler,
|
|
212
|
+
notAuthorizedHandler?: VoidFunction,
|
|
213
|
+
errorHandler?: ErrorHandler,
|
|
214
|
+
fetchStartedHandler?: FetchStartEventHandler,
|
|
215
|
+
fetchEndedHandler?: FetchEndEventHandler,
|
|
216
|
+
}
|
|
217
|
+
): Promise<TOutput|null|undefined> {
|
|
218
|
+
const result = await this.apiRaw<TApiName, TOutput>(apiName, payload, options);
|
|
194
219
|
return result?.payload;
|
|
195
220
|
}
|
|
196
221
|
|
package/src/index.ts
CHANGED
|
@@ -5,3 +5,10 @@ export { default as LambderCaller } from "./LambderCaller.js";
|
|
|
5
5
|
export { default as LambderResponseBuilder } from "./LambderResponseBuilder.js";
|
|
6
6
|
export { default as LambderResolver } from "./LambderResolver.js";
|
|
7
7
|
export { default as LambderSessionManager } from "./LambderSessionManager.js";
|
|
8
|
+
|
|
9
|
+
// Type-safe API contract utilities
|
|
10
|
+
export {
|
|
11
|
+
type ApiContract,
|
|
12
|
+
type ApiInput,
|
|
13
|
+
type ApiOutput,
|
|
14
|
+
} from "./LambderApiContract.js";
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Safety Tests
|
|
3
|
+
*
|
|
4
|
+
* This file tests that the type system works correctly.
|
|
5
|
+
* If this file compiles without errors, the types are working!
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Lambder from '../src/Lambder.js';
|
|
9
|
+
import LambderCaller from '../src/LambderCaller.js';
|
|
10
|
+
import type { ApiContract } from '../src/index.js';
|
|
11
|
+
|
|
12
|
+
// Test contract
|
|
13
|
+
type TestApiContract = {
|
|
14
|
+
getUser: { input: { userId: string }, output: { id: string, name: string } },
|
|
15
|
+
createUser: { input: { name: string, email: string }, output: { id: string, name: string, email: string } },
|
|
16
|
+
listUsers: { input: void, output: Array<{ id: string, name: string }> },
|
|
17
|
+
deleteUser: { input: { userId: string }, output: boolean }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Test 1: LambderCaller Type Safety
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
function testCallerTypes() {
|
|
25
|
+
const caller = new LambderCaller<TestApiContract>({
|
|
26
|
+
apiPath: '/api',
|
|
27
|
+
isCorsEnabled: false
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function test() {
|
|
31
|
+
// ✅ Should work: Correct types
|
|
32
|
+
const user1 = await caller.api('getUser', { userId: '123' });
|
|
33
|
+
const user2 = await caller.api('createUser', { name: 'Alice', email: 'alice@example.com' });
|
|
34
|
+
const users = await caller.api('listUsers', undefined);
|
|
35
|
+
const deleted = await caller.api('deleteUser', { userId: '123' });
|
|
36
|
+
|
|
37
|
+
// Type assertions to verify return types
|
|
38
|
+
if (user1) {
|
|
39
|
+
const name: string = user1.name; // Should work
|
|
40
|
+
const id: string = user1.id; // Should work
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (user2) {
|
|
44
|
+
const email: string = user2.email; // Should work
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (users) {
|
|
48
|
+
const firstUser = users[0];
|
|
49
|
+
if (firstUser) {
|
|
50
|
+
const name: string = firstUser.name; // Should work
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (deleted !== null && deleted !== undefined) {
|
|
55
|
+
const result: boolean = deleted; // Should work
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ❌ These should cause TypeScript errors (commented out to allow compilation):
|
|
59
|
+
|
|
60
|
+
// Wrong property name
|
|
61
|
+
// await caller.api('getUser', { id: '123' }); // Error: should be userId
|
|
62
|
+
|
|
63
|
+
// Missing required property
|
|
64
|
+
// await caller.api('createUser', { name: 'Bob' }); // Error: missing email
|
|
65
|
+
|
|
66
|
+
// Wrong type
|
|
67
|
+
// await caller.api('getUser', { userId: 123 }); // Error: userId should be string
|
|
68
|
+
|
|
69
|
+
// Non-existent API
|
|
70
|
+
// await caller.api('nonExistent', {}); // Error: API doesn't exist
|
|
71
|
+
|
|
72
|
+
// Wrong payload for void input
|
|
73
|
+
// await caller.api('listUsers', { something: true }); // Error: should be undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Test 2: Lambder Type Safety
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
function testLambderTypes() {
|
|
82
|
+
const lambder = new Lambder<TestApiContract>({
|
|
83
|
+
publicPath: './public',
|
|
84
|
+
apiPath: '/api'
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ✅ Should work: Typed API
|
|
88
|
+
lambder.addApi('getUser', async (ctx, resolver) => {
|
|
89
|
+
// ctx.apiPayload should be typed as { userId: string }
|
|
90
|
+
const userId: string = ctx.apiPayload.userId; // Should work
|
|
91
|
+
|
|
92
|
+
// Return correct type
|
|
93
|
+
return resolver.api({ id: userId, name: 'Test' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ✅ Should work: Typed session API
|
|
97
|
+
lambder.addSessionApi('createUser', async (ctx, resolver) => {
|
|
98
|
+
// ctx.apiPayload should be typed as { name: string, email: string }
|
|
99
|
+
const name: string = ctx.apiPayload.name;
|
|
100
|
+
const email: string = ctx.apiPayload.email;
|
|
101
|
+
|
|
102
|
+
return resolver.api({ id: '1', name, email });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ✅ Should work: Void input API
|
|
106
|
+
lambder.addApi('listUsers', async (ctx, resolver) => {
|
|
107
|
+
// ctx.apiPayload should be void/undefined
|
|
108
|
+
return resolver.api([
|
|
109
|
+
{ id: '1', name: 'User 1' },
|
|
110
|
+
{ id: '2', name: 'User 2' }
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ✅ Should work: RegExp (untyped)
|
|
115
|
+
lambder.addApi(/^admin\./, async (ctx, resolver) => {
|
|
116
|
+
// ctx.apiPayload is any (untyped)
|
|
117
|
+
return resolver.api({});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ❌ These should cause TypeScript errors (commented out):
|
|
121
|
+
|
|
122
|
+
// Accessing wrong property
|
|
123
|
+
// lambder.addApi('getUser', async (ctx, resolver) => {
|
|
124
|
+
// const id = ctx.apiPayload.id; // Error: should be userId
|
|
125
|
+
// return resolver.api({ id, name: 'Test' });
|
|
126
|
+
// });
|
|
127
|
+
|
|
128
|
+
// Returning wrong type
|
|
129
|
+
// lambder.addApi('getUser', async (ctx, resolver) => {
|
|
130
|
+
// return resolver.api({ id: '1' }); // Error: missing name property
|
|
131
|
+
// });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// Test 3: Backward Compatibility (No Contract)
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
function testBackwardCompatibility() {
|
|
139
|
+
// Without contract type - should work as before (untyped)
|
|
140
|
+
const caller = new LambderCaller({
|
|
141
|
+
apiPath: '/api',
|
|
142
|
+
isCorsEnabled: false
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const lambder = new Lambder({
|
|
146
|
+
publicPath: './public',
|
|
147
|
+
apiPath: '/api'
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function test() {
|
|
151
|
+
// Should work - untyped
|
|
152
|
+
const result = await caller.api('anyApi', { anything: true });
|
|
153
|
+
|
|
154
|
+
lambder.addApi('anyApi', async (ctx, resolver) => {
|
|
155
|
+
// ctx.apiPayload is any
|
|
156
|
+
return resolver.api({ anything: ctx.apiPayload });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Test 4: apiRaw Method
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
function testApiRaw() {
|
|
166
|
+
const caller = new LambderCaller<TestApiContract>({
|
|
167
|
+
apiPath: '/api',
|
|
168
|
+
isCorsEnabled: false
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function test() {
|
|
172
|
+
// apiRaw should also be typed
|
|
173
|
+
const response = await caller.apiRaw('getUser', { userId: '123' });
|
|
174
|
+
|
|
175
|
+
if (response) {
|
|
176
|
+
// response.payload should be typed
|
|
177
|
+
const user = response.payload;
|
|
178
|
+
if (user) {
|
|
179
|
+
const name: string = user.name; // Should work
|
|
180
|
+
const id: string = user.id; // Should work
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Test 5: Complex Types
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
type ComplexApiContract = {
|
|
191
|
+
update: {
|
|
192
|
+
input: { id: string } & Partial<{ name: string, email: string }>,
|
|
193
|
+
output: { id: string, name: string, email: string }
|
|
194
|
+
},
|
|
195
|
+
search: {
|
|
196
|
+
input: { query: string, filters?: { role?: string, active?: boolean } },
|
|
197
|
+
output: Array<{ id: string, name: string }>
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function testComplexTypes() {
|
|
202
|
+
const caller = new LambderCaller<ComplexApiContract>({
|
|
203
|
+
apiPath: '/api',
|
|
204
|
+
isCorsEnabled: false
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function test() {
|
|
208
|
+
// Should work with partial update
|
|
209
|
+
await caller.api('update', { id: '1', name: 'New Name' });
|
|
210
|
+
await caller.api('update', { id: '1', email: 'new@email.com' });
|
|
211
|
+
await caller.api('update', { id: '1' }); // Only id
|
|
212
|
+
|
|
213
|
+
// Should work with optional nested object
|
|
214
|
+
await caller.api('search', { query: 'test' });
|
|
215
|
+
await caller.api('search', { query: 'test', filters: { role: 'admin' } });
|
|
216
|
+
await caller.api('search', { query: 'test', filters: { role: 'admin', active: true } });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// If this file compiles without errors, the type system is working! ✅
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
export { };
|