ts-typed-api 0.1.13 → 0.1.14

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/dist/openapi.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ApiDefinitionSchema } from './definition';
2
- export declare function generateOpenApiSpec(definition: ApiDefinitionSchema, options?: {
2
+ export declare function generateOpenApiSpec(definitions: ApiDefinitionSchema | ApiDefinitionSchema[], options?: {
3
3
  info?: {
4
4
  title?: string;
5
5
  version?: string;
package/dist/openapi.js CHANGED
@@ -5,12 +5,16 @@ const zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
5
5
  const zod_1 = require("zod");
6
6
  // Extend Zod with OpenAPI capabilities
7
7
  (0, zod_to_openapi_1.extendZodWithOpenApi)(zod_1.z);
8
- function generateOpenApiSpec(definition, options = {}) {
8
+ function generateOpenApiSpec(definitions, options = {}) {
9
+ // Normalize input to always be an array
10
+ const definitionArray = Array.isArray(definitions) ? definitions : [definitions];
9
11
  const registry = new zod_to_openapi_1.OpenAPIRegistry();
10
12
  // Helper to convert Zod schema to OpenAPI schema component
11
13
  function registerSchema(name, schema) {
12
14
  try {
13
- return registry.register(name, schema); // Cast to any to handle complex Zod types
15
+ // Add a unique identifier to ensure no schema name conflicts across multiple definitions
16
+ const uniqueName = `${name}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
17
+ return registry.register(uniqueName, schema); // Cast to any to handle complex Zod types
14
18
  }
15
19
  catch (error) {
16
20
  console.warn(`Could not register schema ${name}: ${error.message}`);
@@ -27,7 +31,7 @@ function generateOpenApiSpec(definition, options = {}) {
27
31
  name: key,
28
32
  in: inType,
29
33
  required: !val.isOptional(),
30
- schema: registerSchema(`${inType}_${key}_${Date.now()}`, val), // Unique name for registration
34
+ schema: registerSchema(`${inType}_${key}`, val), // Unique name for registration
31
35
  description: val.description,
32
36
  }));
33
37
  }
@@ -38,67 +42,69 @@ function generateOpenApiSpec(definition, options = {}) {
38
42
  required: true, // Assuming body is required if schema is provided
39
43
  content: {
40
44
  'application/json': {
41
- schema: registerSchema(`RequestBody_${Date.now()}`, schema), // Unique name
45
+ schema: registerSchema('RequestBody', schema), // Unique name
42
46
  },
43
47
  },
44
48
  };
45
49
  }
46
- // Iterate over the API definition to register routes
47
- Object.keys(definition.endpoints).forEach(domainNameKey => {
48
- // domainNameKey is a string, representing the domain like 'users', 'products'
49
- const domain = definition.endpoints[domainNameKey];
50
- Object.keys(domain).forEach(routeNameKey => {
51
- // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
52
- const route = domain[routeNameKey];
53
- const parameters = [];
54
- if (route.params) {
55
- parameters.push(...zodSchemaToOpenApiParameter(route.params, 'path'));
56
- }
57
- if (route.query) {
58
- parameters.push(...zodSchemaToOpenApiParameter(route.query, 'query'));
59
- }
60
- const requestBody = zodSchemaToOpenApiRequestBody(route.body);
61
- const responses = {};
62
- for (const statusCode in route.responses) {
63
- const responseSchema = route.responses[parseInt(statusCode)];
64
- if (responseSchema) {
65
- responses[statusCode] = {
66
- description: `Response for status code ${statusCode}`,
50
+ // Iterate over multiple API definitions to register routes
51
+ definitionArray.forEach((definition) => {
52
+ Object.keys(definition.endpoints).forEach(domainNameKey => {
53
+ // domainNameKey is a string, representing the domain like 'users', 'products'
54
+ const domain = definition.endpoints[domainNameKey];
55
+ Object.keys(domain).forEach(routeNameKey => {
56
+ // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
57
+ const route = domain[routeNameKey];
58
+ const parameters = [];
59
+ if (route.params) {
60
+ parameters.push(...zodSchemaToOpenApiParameter(route.params, 'path'));
61
+ }
62
+ if (route.query) {
63
+ parameters.push(...zodSchemaToOpenApiParameter(route.query, 'query'));
64
+ }
65
+ const requestBody = zodSchemaToOpenApiRequestBody(route.body);
66
+ const responses = {};
67
+ for (const statusCode in route.responses) {
68
+ const responseSchema = route.responses[parseInt(statusCode)];
69
+ if (responseSchema) {
70
+ responses[statusCode] = {
71
+ description: `Response for status code ${statusCode}`,
72
+ content: {
73
+ 'application/json': {
74
+ schema: registerSchema(`Response_${statusCode}_${routeNameKey}_${domainNameKey}`, responseSchema),
75
+ },
76
+ },
77
+ };
78
+ }
79
+ }
80
+ // Add 422 response if not already defined, as it's a default in createResponses
81
+ // Assuming route.responses[422] would exist if it's a standard part of the definition
82
+ if (!responses['422'] && route.responses && route.responses[422]) {
83
+ responses['422'] = {
84
+ description: 'Validation Error',
67
85
  content: {
68
86
  'application/json': {
69
- schema: registerSchema(`Response_${statusCode}_${routeNameKey}_${domainNameKey}`, responseSchema),
87
+ schema: registerSchema(`Response_422_${routeNameKey}_${domainNameKey}`, route.responses[422]),
70
88
  },
71
89
  },
72
90
  };
73
91
  }
74
- }
75
- // Add 422 response if not already defined, as it's a default in createResponses
76
- // Assuming route.responses[422] would exist if it's a standard part of the definition
77
- if (!responses['422'] && route.responses && route.responses[422]) {
78
- responses['422'] = {
79
- description: 'Validation Error',
80
- content: {
81
- 'application/json': {
82
- schema: registerSchema(`Response_422_${routeNameKey}_${domainNameKey}`, route.responses[422]),
83
- },
84
- },
92
+ const operation = {
93
+ summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
94
+ tags: [domainNameKey], // Use domainNameKey for tags
95
+ parameters: parameters.length > 0 ? parameters : undefined,
96
+ requestBody: requestBody,
97
+ responses: responses,
85
98
  };
86
- }
87
- const operation = {
88
- summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
89
- tags: [domainNameKey], // Use domainNameKey for tags
90
- parameters: parameters.length > 0 ? parameters : undefined,
91
- requestBody: requestBody,
92
- responses: responses,
93
- };
94
- // Register the route with the registry
95
- // The path needs to be transformed from Express-style (:param) to OpenAPI-style ({param})
96
- const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
97
- registry.registerPath({
98
- method: route.method.toLowerCase(), // Ensure method is lowercase
99
- path: openApiPath,
100
- ...operation,
101
- // Add description or other OpenAPI fields if available in RouteSchema
99
+ // Register the route with the registry
100
+ // The path needs to be transformed from Express-style (:param) to OpenAPI-style ({param})
101
+ const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
102
+ registry.registerPath({
103
+ method: route.method.toLowerCase(), // Ensure method is lowercase
104
+ path: openApiPath,
105
+ ...operation,
106
+ // Add description or other OpenAPI fields if available in RouteSchema
107
+ });
102
108
  });
103
109
  });
104
110
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-typed-api",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "A lightweight, type-safe RPC library for TypeScript with Zod validation",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/openapi.ts CHANGED
@@ -6,7 +6,7 @@ import { z, ZodTypeAny } from 'zod';
6
6
  extendZodWithOpenApi(z);
7
7
 
8
8
  export function generateOpenApiSpec(
9
- definition: ApiDefinitionSchema,
9
+ definitions: ApiDefinitionSchema | ApiDefinitionSchema[],
10
10
  options: {
11
11
  info?: {
12
12
  title?: string;
@@ -16,12 +16,17 @@ export function generateOpenApiSpec(
16
16
  servers?: { url: string, description?: string }[];
17
17
  } = {}
18
18
  ) {
19
+ // Normalize input to always be an array
20
+ const definitionArray = Array.isArray(definitions) ? definitions : [definitions];
21
+
19
22
  const registry = new OpenAPIRegistry();
20
23
 
21
24
  // Helper to convert Zod schema to OpenAPI schema component
22
25
  function registerSchema(name: string, schema: ZodTypeAny) {
23
26
  try {
24
- return registry.register(name, schema as any); // Cast to any to handle complex Zod types
27
+ // Add a unique identifier to ensure no schema name conflicts across multiple definitions
28
+ const uniqueName = `${name}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
29
+ return registry.register(uniqueName, schema as any); // Cast to any to handle complex Zod types
25
30
  } catch (error) {
26
31
  console.warn(`Could not register schema ${name}: ${(error as Error).message}`);
27
32
  // Fallback or simplified schema if registration fails
@@ -37,7 +42,7 @@ export function generateOpenApiSpec(
37
42
  name: key,
38
43
  in: inType,
39
44
  required: !val.isOptional(),
40
- schema: registerSchema(`${inType}_${key}_${Date.now()}`, val), // Unique name for registration
45
+ schema: registerSchema(`${inType}_${key}`, val), // Unique name for registration
41
46
  description: val.description,
42
47
  }));
43
48
  }
@@ -48,76 +53,77 @@ export function generateOpenApiSpec(
48
53
  required: true, // Assuming body is required if schema is provided
49
54
  content: {
50
55
  'application/json': {
51
- schema: registerSchema(`RequestBody_${Date.now()}`, schema), // Unique name
56
+ schema: registerSchema('RequestBody', schema), // Unique name
52
57
  },
53
58
  },
54
59
  };
55
60
  }
56
61
 
57
- // Iterate over the API definition to register routes
58
- Object.keys(definition.endpoints).forEach(domainNameKey => {
59
- // domainNameKey is a string, representing the domain like 'users', 'products'
60
- const domain = definition.endpoints[domainNameKey];
61
- Object.keys(domain).forEach(routeNameKey => {
62
- // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
63
- const route: RouteSchema = domain[routeNameKey];
64
-
65
- const parameters: any[] = [];
66
- if (route.params) {
67
- parameters.push(...zodSchemaToOpenApiParameter(route.params, 'path'));
68
- }
69
- if (route.query) {
70
- parameters.push(...zodSchemaToOpenApiParameter(route.query, 'query'));
71
- }
72
-
73
- const requestBody = zodSchemaToOpenApiRequestBody(route.body);
74
-
75
- const responses: any = {};
76
- for (const statusCode in route.responses) {
77
- const responseSchema = route.responses[parseInt(statusCode)];
78
- if (responseSchema) {
79
- responses[statusCode] = {
80
- description: `Response for status code ${statusCode}`,
62
+ // Iterate over multiple API definitions to register routes
63
+ definitionArray.forEach((definition) => {
64
+ Object.keys(definition.endpoints).forEach(domainNameKey => {
65
+ // domainNameKey is a string, representing the domain like 'users', 'products'
66
+ const domain = definition.endpoints[domainNameKey];
67
+ Object.keys(domain).forEach(routeNameKey => {
68
+ // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
69
+ const route: RouteSchema = domain[routeNameKey];
70
+
71
+ const parameters: any[] = [];
72
+ if (route.params) {
73
+ parameters.push(...zodSchemaToOpenApiParameter(route.params, 'path'));
74
+ }
75
+ if (route.query) {
76
+ parameters.push(...zodSchemaToOpenApiParameter(route.query, 'query'));
77
+ }
78
+
79
+ const requestBody = zodSchemaToOpenApiRequestBody(route.body);
80
+
81
+ const responses: any = {};
82
+ for (const statusCode in route.responses) {
83
+ const responseSchema = route.responses[parseInt(statusCode)];
84
+ if (responseSchema) {
85
+ responses[statusCode] = {
86
+ description: `Response for status code ${statusCode}`,
87
+ content: {
88
+ 'application/json': {
89
+ schema: registerSchema(`Response_${statusCode}_${routeNameKey}_${domainNameKey}`, responseSchema),
90
+ },
91
+ },
92
+ };
93
+ }
94
+ }
95
+
96
+ // Add 422 response if not already defined, as it's a default in createResponses
97
+ // Assuming route.responses[422] would exist if it's a standard part of the definition
98
+ if (!responses['422'] && route.responses && route.responses[422]) {
99
+ responses['422'] = {
100
+ description: 'Validation Error',
81
101
  content: {
82
102
  'application/json': {
83
- schema: registerSchema(`Response_${statusCode}_${routeNameKey}_${domainNameKey}`, responseSchema),
103
+ schema: registerSchema(`Response_422_${routeNameKey}_${domainNameKey}`, route.responses[422]),
84
104
  },
85
105
  },
86
106
  };
87
107
  }
88
- }
89
-
90
- // Add 422 response if not already defined, as it's a default in createResponses
91
- // Assuming route.responses[422] would exist if it's a standard part of the definition
92
- if (!responses['422'] && route.responses && route.responses[422]) {
93
- responses['422'] = {
94
- description: 'Validation Error',
95
- content: {
96
- 'application/json': {
97
- schema: registerSchema(`Response_422_${routeNameKey}_${domainNameKey}`, route.responses[422]),
98
- },
99
- },
108
+
109
+ const operation = {
110
+ summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
111
+ tags: [domainNameKey], // Use domainNameKey for tags
112
+ parameters: parameters.length > 0 ? parameters : undefined,
113
+ requestBody: requestBody,
114
+ responses: responses,
100
115
  };
101
- }
102
-
103
-
104
- const operation = {
105
- summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
106
- tags: [domainNameKey], // Use domainNameKey for tags
107
- parameters: parameters.length > 0 ? parameters : undefined,
108
- requestBody: requestBody,
109
- responses: responses,
110
- };
111
-
112
- // Register the route with the registry
113
- // The path needs to be transformed from Express-style (:param) to OpenAPI-style ({param})
114
- const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
115
-
116
- registry.registerPath({
117
- method: route.method.toLowerCase() as any, // Ensure method is lowercase
118
- path: openApiPath,
119
- ...operation,
120
- // Add description or other OpenAPI fields if available in RouteSchema
116
+
117
+ // Register the route with the registry
118
+ // The path needs to be transformed from Express-style (:param) to OpenAPI-style ({param})
119
+ const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
120
+
121
+ registry.registerPath({
122
+ method: route.method.toLowerCase() as any, // Ensure method is lowercase
123
+ path: openApiPath,
124
+ ...operation,
125
+ // Add description or other OpenAPI fields if available in RouteSchema
126
+ });
121
127
  });
122
128
  });
123
129
  });
@@ -1,269 +0,0 @@
1
- import { describe, test, expect } from '@jest/globals';
2
- import fetch from 'node-fetch';
3
- import { z } from 'zod';
4
- import { CreateApiDefinition, CreateResponses, RegisterHandlers } from '../src';
5
- import express from 'express';
6
- import { Server } from 'http';
7
-
8
- describe('Strict Validation Tests', () => {
9
- let server: Server;
10
- const port = 3004;
11
- const baseUrl = `http://localhost:${port}`;
12
-
13
- beforeAll(async () => {
14
- await startTestServer();
15
- });
16
-
17
- afterAll(async () => {
18
- if (server) {
19
- server.close();
20
- }
21
- });
22
-
23
- async function startTestServer(): Promise<void> {
24
- return new Promise((resolve) => {
25
- // Create API definition with strict schemas
26
- const StrictApiDefinition = CreateApiDefinition({
27
- prefix: '/api',
28
- endpoints: {
29
- test: {
30
- strictResponse: {
31
- path: '/strict-response',
32
- method: 'GET',
33
- responses: CreateResponses({
34
- 200: z.object({
35
- name: z.string(),
36
- age: z.number()
37
- })
38
- })
39
- },
40
- strictBody: {
41
- path: '/strict-body',
42
- method: 'POST',
43
- body: z.object({
44
- title: z.string(),
45
- count: z.number()
46
- }),
47
- responses: CreateResponses({
48
- 200: z.object({
49
- success: z.boolean()
50
- })
51
- })
52
- },
53
- strictQuery: {
54
- path: '/strict-query',
55
- method: 'GET',
56
- query: z.object({
57
- filter: z.string(),
58
- limit: z.number()
59
- }),
60
- responses: CreateResponses({
61
- 200: z.object({
62
- results: z.array(z.string())
63
- })
64
- })
65
- }
66
- }
67
- }
68
- });
69
-
70
- const app = express();
71
- app.use(express.json());
72
-
73
- RegisterHandlers(app, StrictApiDefinition, {
74
- test: {
75
- strictResponse: async (req, res) => {
76
- // Try to send response with extra properties
77
- // This should fail due to strict validation
78
- const responseData = {
79
- name: 'John',
80
- age: 30,
81
- // These extra properties should cause validation to fail
82
- extraProperty: 'should not be allowed',
83
- anotherExtra: 123
84
- };
85
-
86
- res.respond(200, responseData);
87
- },
88
- strictBody: async (req, res) => {
89
- // The request body should be strictly validated
90
- // Extra properties in the request should cause validation errors
91
- res.respond(200, { success: true });
92
- },
93
- strictQuery: async (req, res) => {
94
- // Query parameters should be strictly validated
95
- res.respond(200, { results: ['item1', 'item2'] });
96
- }
97
- }
98
- });
99
-
100
- server = app.listen(port, () => {
101
- resolve();
102
- });
103
- });
104
- }
105
-
106
- test('should fail when response contains extra properties', async () => {
107
- // This should return a 500 error because the response contains extra properties
108
- const response = await fetch(`${baseUrl}/api/strict-response`);
109
-
110
- expect(response.status).toBe(500);
111
-
112
- const data = await response.json();
113
- expect(data).toHaveProperty('error');
114
- expect(Array.isArray(data.error)).toBe(true);
115
- expect(data.error[0].message).toContain('Internal server error');
116
- });
117
-
118
- test('should fail when request body contains extra properties', async () => {
119
- const requestBody = {
120
- title: 'Test Title',
121
- count: 5,
122
- // Extra properties that should cause validation to fail
123
- extraField: 'not allowed',
124
- anotherField: true
125
- };
126
-
127
- const response = await fetch(`${baseUrl}/api/strict-body`, {
128
- method: 'POST',
129
- headers: {
130
- 'Content-Type': 'application/json'
131
- },
132
- body: JSON.stringify(requestBody)
133
- });
134
-
135
- expect(response.status).toBe(422);
136
-
137
- const data = await response.json();
138
- expect(data).toHaveProperty('error');
139
- expect(Array.isArray(data.error)).toBe(true);
140
-
141
- // Should contain validation errors for the extra properties
142
- const errorMessages = data.error.map((err: any) => err.message);
143
- expect(errorMessages.some((msg: string) => msg.includes('Unrecognized key'))).toBe(true);
144
- });
145
-
146
- test('should fail when query parameters contain extra properties', async () => {
147
- // Add extra query parameters that aren't in the schema
148
- const queryParams = new URLSearchParams({
149
- filter: 'test',
150
- limit: '10',
151
- // Extra parameters that should cause validation to fail
152
- extraParam: 'not allowed',
153
- anotherParam: 'also not allowed'
154
- });
155
-
156
- const response = await fetch(`${baseUrl}/api/strict-query?${queryParams}`);
157
-
158
- expect(response.status).toBe(422);
159
-
160
- const data = await response.json();
161
- expect(data).toHaveProperty('error');
162
- expect(Array.isArray(data.error)).toBe(true);
163
-
164
- // Should contain validation errors for the extra query parameters
165
- const errorMessages = data.error.map((err: any) => err.message);
166
- expect(errorMessages.some((msg: string) => msg.includes('Unrecognized key'))).toBe(true);
167
- });
168
-
169
- test('should succeed when request matches schema exactly', async () => {
170
- const requestBody = {
171
- title: 'Valid Title',
172
- count: 42
173
- // No extra properties
174
- };
175
-
176
- const response = await fetch(`${baseUrl}/api/strict-body`, {
177
- method: 'POST',
178
- headers: {
179
- 'Content-Type': 'application/json'
180
- },
181
- body: JSON.stringify(requestBody)
182
- });
183
-
184
- expect(response.status).toBe(200);
185
-
186
- const data = await response.json();
187
- expect(data).toHaveProperty('data');
188
- expect(data.data.success).toBe(true);
189
- });
190
-
191
- test('should succeed when query parameters match schema exactly', async () => {
192
- const queryParams = new URLSearchParams({
193
- filter: 'test-filter',
194
- limit: '5'
195
- // No extra parameters
196
- });
197
-
198
- const response = await fetch(`${baseUrl}/api/strict-query?${queryParams}`);
199
-
200
- expect(response.status).toBe(200);
201
-
202
- const data = await response.json();
203
- expect(data).toHaveProperty('data');
204
- expect(data.data.results).toEqual(['item1', 'item2']);
205
- });
206
-
207
- describe('Schema Definition Strictness', () => {
208
- test('CreateResponses should make schemas strict', () => {
209
- const responses = CreateResponses({
210
- 200: z.object({
211
- name: z.string(),
212
- age: z.number()
213
- })
214
- });
215
-
216
- const testData = {
217
- data: {
218
- name: 'John',
219
- age: 30,
220
- extraProperty: 'should fail'
221
- },
222
- error: null
223
- };
224
-
225
- // This should fail validation due to strict mode
226
- const result = responses[200].safeParse(testData);
227
- expect(result.success).toBe(false);
228
-
229
- if (!result.success) {
230
- const errorMessages = result.error.issues.map((err: { message: string }) => err.message);
231
- expect(errorMessages.some(msg => msg.includes('Unrecognized key'))).toBe(true);
232
- }
233
- });
234
-
235
- test('CreateApiDefinition should make all schemas strict', () => {
236
- const apiDef = CreateApiDefinition({
237
- endpoints: {
238
- test: {
239
- endpoint: {
240
- path: '/test',
241
- method: 'POST',
242
- body: z.object({
243
- name: z.string()
244
- }),
245
- responses: CreateResponses({
246
- 200: z.object({
247
- success: z.boolean()
248
- })
249
- })
250
- }
251
- }
252
- }
253
- });
254
-
255
- // Test that body schema is strict
256
- const bodySchema = apiDef.endpoints.test.endpoint.body;
257
- const bodyResult = bodySchema?.safeParse({
258
- name: 'test',
259
- extraField: 'should fail'
260
- });
261
-
262
- expect(bodyResult?.success).toBe(false);
263
- if (bodyResult && !bodyResult.success) {
264
- const errorMessages = bodyResult.error.issues.map((err: { message: string }) => err.message);
265
- expect(errorMessages.some(msg => msg.includes('Unrecognized key'))).toBe(true);
266
- }
267
- });
268
- });
269
- });