Skip to content

Commit 2f26115

Browse files
committed
Improved composable handlers and contracts
1 parent b37a28f commit 2f26115

7 files changed

Lines changed: 167 additions & 51 deletions

File tree

examples/complex/handlers/orders.handlers.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import { z } from 'zod/v4';
22
import { orderDb, productDb } from '../utils/database';
33
import { paginate } from '../utils/pagination';
44
import { OrderItem } from '../schemas/orders';
5-
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
5+
import { ordersContract } from '../contracts/orders.contract';
6+
import { defineHandlers } from '../../../src/handler';
67

78
type OrderItemType = z.infer<typeof OrderItem>;
89

910
/**
1011
* Order handlers - implement all order-related endpoints
12+
* Each handler is typed against its contract operation for full type safety
1113
*/
1214

13-
export const orderHandlers = {
14-
getOrders: async (request: AuthenticatedRequest) => {
15+
export const orderHandlers = defineHandlers(ordersContract, {
16+
getOrders: async (request) => {
1517
const {
1618
page = 1,
1719
limit = 20,
@@ -48,7 +50,7 @@ export const orderHandlers = {
4850
});
4951
},
5052

51-
getOrderById: async (request: AuthenticatedRequest) => {
53+
getOrderById: async (request) => {
5254
const { id } = request.validatedPathParams;
5355
const order = orderDb.findById(id);
5456

@@ -70,7 +72,7 @@ export const orderHandlers = {
7072
});
7173
},
7274

73-
createOrder: async (request: AuthenticatedRequest) => {
75+
createOrder: async (request) => {
7476
const data = request.validatedBody;
7577

7678
// Validate products exist and calculate totals
@@ -138,7 +140,7 @@ export const orderHandlers = {
138140
});
139141
},
140142

141-
updateOrderStatus: async (request: AuthenticatedRequest) => {
143+
updateOrderStatus: async (request) => {
142144
const { id } = request.validatedPathParams;
143145
const { status } = request.validatedBody;
144146

@@ -173,7 +175,7 @@ export const orderHandlers = {
173175
});
174176
},
175177

176-
getUserOrders: async (request: AuthenticatedRequest) => {
178+
getUserOrders: async (request) => {
177179
const { id } = request.validatedPathParams;
178180
const { page = 1, limit = 20, status } = request.validatedQuery || {};
179181

@@ -194,4 +196,4 @@ export const orderHandlers = {
194196
},
195197
});
196198
},
197-
};
199+
});

examples/complex/handlers/products.handlers.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { productDb } from '../utils/database';
22
import { paginate } from '../utils/pagination';
3-
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
3+
import { productsContract } from '../contracts/products.contract';
4+
import { defineHandlers } from '../../../src/handler';
45

56
/**
67
* Product handlers - implement all product-related endpoints
8+
* Each handler is typed against its contract operation for full type safety
79
*/
810

9-
export const productHandlers = {
10-
getProducts: async (request: AuthenticatedRequest) => {
11+
export const productHandlers = defineHandlers(productsContract, {
12+
getProducts: async (request) => {
1113
const {
1214
page = 1,
1315
limit = 20,
@@ -41,7 +43,7 @@ export const productHandlers = {
4143
});
4244
},
4345

44-
getProductById: async (request: AuthenticatedRequest) => {
46+
getProductById: async (request) => {
4547
const { id } = request.validatedPathParams;
4648
const product = productDb.findById(id);
4749

@@ -63,7 +65,7 @@ export const productHandlers = {
6365
});
6466
},
6567

66-
createProduct: async (request: AuthenticatedRequest) => {
68+
createProduct: async (request) => {
6769
const data = request.validatedBody;
6870
const product = productDb.create({
6971
name: data.name,
@@ -85,7 +87,7 @@ export const productHandlers = {
8587
});
8688
},
8789

88-
updateProduct: async (request: AuthenticatedRequest) => {
90+
updateProduct: async (request) => {
8991
const { id } = request.validatedPathParams;
9092
const data = request.validatedBody;
9193

@@ -120,7 +122,7 @@ export const productHandlers = {
120122
});
121123
},
122124

123-
deleteProduct: async (request: AuthenticatedRequest) => {
125+
deleteProduct: async (request) => {
124126
const { id } = request.validatedPathParams;
125127

126128
const product = productDb.findById(id);
@@ -143,4 +145,4 @@ export const productHandlers = {
143145
body: undefined,
144146
});
145147
},
146-
};
148+
});

examples/complex/handlers/users.handlers.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { userDb } from '../utils/database';
22
import { paginate } from '../utils/pagination';
3-
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
3+
import { usersContract } from '../contracts/users.contract';
4+
import { defineHandlers } from '../../../src/handler';
45

56
/**
67
* User handlers - implement all user-related endpoints
8+
* Each handler is typed against its contract operation for full type safety
79
*/
810

9-
export const userHandlers = {
10-
getUsers: async (request: AuthenticatedRequest) => {
11+
export const userHandlers = defineHandlers(usersContract, {
12+
getUsers: async (request) => {
1113
const { page = 1, limit = 20, role, status, search } = request.validatedQuery || {};
1214

1315
const filters = { role, status, search };
@@ -24,8 +26,8 @@ export const userHandlers = {
2426
});
2527
},
2628

27-
getUserById: async (request: AuthenticatedRequest) => {
28-
const { id } = request.validatedPathParams;
29+
getUserById: async (request) => {
30+
const { id } = request.validatedParams;
2931
const user = userDb.findById(id);
3032

3133
if (!user) {
@@ -46,7 +48,7 @@ export const userHandlers = {
4648
});
4749
},
4850

49-
createUser: async (request: AuthenticatedRequest) => {
51+
createUser: async (request) => {
5052
const data = request.validatedBody;
5153

5254
// Check if email already exists
@@ -78,8 +80,8 @@ export const userHandlers = {
7880
});
7981
},
8082

81-
updateUser: async (request: AuthenticatedRequest) => {
82-
const { id } = request.validatedPathParams;
83+
updateUser: async (request) => {
84+
const { id } = request.validatedParams;
8385
const data = request.validatedBody;
8486

8587
const user = userDb.findById(id);
@@ -113,8 +115,8 @@ export const userHandlers = {
113115
});
114116
},
115117

116-
deleteUser: async (request: AuthenticatedRequest) => {
117-
const { id } = request.validatedPathParams;
118+
deleteUser: async (request) => {
119+
const { id } = request.validatedParams;
118120

119121
const user = userDb.findById(id);
120122
if (!user) {
@@ -136,4 +138,4 @@ export const userHandlers = {
136138
body: undefined,
137139
});
138140
},
139-
};
141+
});

examples/complex/index.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import { createServer } from 'http';
33
import { contract } from './contracts';
44
import { createOpenApiSpecification } from '../../src/openapi';
55
import { createRouter } from '../../src/index.ts';
6-
import { userHandlers } from './handlers/users.handlers';
7-
import { productHandlers } from './handlers/products.handlers';
8-
import { orderHandlers } from './handlers/orders.handlers';
6+
import { userHandlers, orderHandlers, productHandlers } from './handlers';
97
import { initializeSampleData } from './utils/database';
108
import { createSpotlightElementsHtml } from './utils/docs';
119
import { withAuth } from './middleware/auth.middleware';
@@ -48,27 +46,12 @@ initializeSampleData();
4846
*/
4947
const router = createRouter({
5048
contract,
51-
before: [
52-
// Add authentication middleware to all requests
53-
withAuth,
54-
],
49+
before: [withAuth],
5550
handlers: {
5651
// User handlers
57-
getUsers: userHandlers.getUsers,
58-
getUserById: userHandlers.getUserById,
59-
createUser: userHandlers.createUser,
60-
updateUser: userHandlers.updateUser,
61-
deleteUser: userHandlers.deleteUser,
62-
getProducts: productHandlers.getProducts,
63-
getProductById: productHandlers.getProductById,
64-
createProduct: productHandlers.createProduct,
65-
updateProduct: productHandlers.updateProduct,
66-
deleteProduct: productHandlers.deleteProduct,
67-
getOrders: orderHandlers.getOrders,
68-
getOrderById: orderHandlers.getOrderById,
69-
createOrder: orderHandlers.createOrder,
70-
updateOrderStatus: orderHandlers.updateOrderStatus,
71-
getUserOrders: orderHandlers.getUserOrders,
52+
...userHandlers,
53+
...productHandlers,
54+
...orderHandlers,
7255
getSpec: async (request) => {
7356
return request.respond({
7457
status: 200,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "itty-spec",
3-
"version": "0.2.7",
3+
"version": "0.2.8",
44
"description": "Type-safe contract first itty-router",
55
"type": "module",
66
"main": "./dist/index.cjs",

src/contract.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { ContractDefinition } from './types';
1+
import { IRequest } from 'itty-router';
2+
import {
3+
ContractDefinition,
4+
ContractOperation,
5+
ContractOperationHandler,
6+
HandlersForContract,
7+
} from './types';
28

39
/**
410
* Creates a contract from a contract definition
@@ -54,3 +60,73 @@ import { ContractDefinition } from './types';
5460
export function createContract<T extends ContractDefinition>(definition: T): T {
5561
return definition;
5662
}
63+
64+
/**
65+
* Define handlers for a contract with type safety
66+
* This function validates that handlers match the contract and can be used
67+
* to define handlers in separate files that will be combined later
68+
*
69+
* The function accepts handlers that may use extended request types (e.g., AuthenticatedRequest)
70+
* as long as they are compatible with the contract's ContractRequest type.
71+
*
72+
* @typeParam TContract - The contract definition type
73+
* @typeParam Args - Additional arguments passed to handlers (defaults to any[])
74+
*
75+
* @param contract - The contract definition to validate handlers against
76+
* @param handlers - Handlers object that must match all operations in the contract.
77+
* Handlers can use extended request types (e.g., AuthenticatedRequest)
78+
* as long as they extend IRequest and are compatible with ContractRequest.
79+
* @returns The handlers object with full type safety, ready to be combined with other handlers
80+
*
81+
* @example
82+
* ```typescript
83+
* // handlers/users.handlers.ts
84+
* import { usersContract } from '../contracts/users.contract';
85+
* import { defineHandlers } from 'itty-spec';
86+
* import type { AuthenticatedRequest } from '../middleware/auth.middleware';
87+
*
88+
* export const userHandlers = defineHandlers(usersContract, {
89+
* getUsers: async (request: AuthenticatedRequest) => {
90+
* // request is fully typed based on usersContract.getUsers
91+
* // and also has userId, userRole from AuthenticatedRequest
92+
* const { page, limit } = request.validatedQuery;
93+
* return request.respond({
94+
* status: 200,
95+
* contentType: 'application/json',
96+
* body: { users: [] },
97+
* });
98+
* },
99+
* getUserById: async (request: AuthenticatedRequest) => {
100+
* // implementation
101+
* },
102+
* // TypeScript will ensure all contract operations have handlers
103+
* });
104+
* ```
105+
*
106+
* @example
107+
* ```typescript
108+
* // index.ts - combine handlers later
109+
* import { userHandlers } from './handlers/users.handlers';
110+
* import { productHandlers } from './handlers/products.handlers';
111+
* import { contract } from './contracts';
112+
*
113+
* const router = createRouter({
114+
* contract,
115+
* handlers: {
116+
* ...userHandlers,
117+
* ...productHandlers,
118+
* },
119+
* });
120+
* ```
121+
*/
122+
export function defineHandlers<TContract extends ContractDefinition, Args extends any[] = any[]>(
123+
contract: TContract,
124+
handlers: {
125+
[K in keyof TContract]: (
126+
request: IRequest & Parameters<ContractOperationHandler<TContract[K], Args>>[0],
127+
...args: Args
128+
) => ReturnType<ContractOperationHandler<TContract[K], Args>>;
129+
}
130+
): HandlersForContract<TContract, Args> {
131+
return handlers as HandlersForContract<TContract, Args>;
132+
}

src/types.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,57 @@ export type ContractOperationHandler<O extends ContractOperation, Args extends a
589589
...args: Args
590590
) => Promise<ContractOperationResponse<O>>;
591591

592+
/**
593+
* Extract handler type for a specific contract operation
594+
* Useful when defining handlers in separate files that reference contract operations
595+
*
596+
* @example
597+
* ```typescript
598+
* import { usersContract } from './contracts/users.contract';
599+
* import { HandlerForContract } from 'itty-spec';
600+
*
601+
* type GetUsersHandler = HandlerForContract<typeof usersContract, 'getUsers'>;
602+
*
603+
* export const getUsers: GetUsersHandler = async (request) => {
604+
* // request is fully typed based on usersContract.getUsers
605+
* const { page, limit } = request.validatedQuery;
606+
* // ...
607+
* };
608+
* ```
609+
*/
610+
export type HandlerForContract<
611+
TContract extends ContractDefinition,
612+
K extends keyof TContract,
613+
Args extends any[] = any[],
614+
> = ContractOperationHandler<TContract[K], Args>;
615+
616+
/**
617+
* Extract all handler types for a contract
618+
* Useful for type-checking a complete handlers object against a contract
619+
*
620+
* @example
621+
* ```typescript
622+
* import { usersContract } from './contracts/users.contract';
623+
* import { HandlersForContract } from 'itty-spec';
624+
*
625+
* const userHandlers: HandlersForContract<typeof usersContract> = {
626+
* getUsers: async (request) => {
627+
* // implementation
628+
* },
629+
* getUserById: async (request) => {
630+
* // implementation
631+
* },
632+
* // TypeScript will ensure all operations are handled
633+
* };
634+
* ```
635+
*/
636+
export type HandlersForContract<
637+
TContract extends ContractDefinition,
638+
Args extends any[] = any[],
639+
> = {
640+
[K in keyof TContract]: HandlerForContract<TContract, K, Args>;
641+
};
642+
592643
/**
593644
* Contract router type - maps operation IDs to their handlers
594645
* Note: Renamed from Router to avoid conflict with itty-router's Router function

0 commit comments

Comments
 (0)