ts-typed-api 0.1.0 → 0.1.2

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.
Files changed (38) hide show
  1. package/README.md +58 -1
  2. package/dist/definition.d.ts +70 -0
  3. package/dist/definition.js +44 -1
  4. package/dist/handler.js +159 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/router.d.ts +7 -1
  7. package/examples/file-upload-example.ts +158 -0
  8. package/package.json +3 -1
  9. package/src/definition.ts +97 -0
  10. package/src/handler.ts +159 -1
  11. package/src/index.ts +1 -0
  12. package/src/router.ts +8 -1
  13. package/dist/apiClient.d.ts +0 -147
  14. package/dist/apiClient.js +0 -206
  15. package/dist/example-server/client.d.ts +0 -2
  16. package/dist/example-server/client.js +0 -19
  17. package/dist/example-server/definitions.d.ts +0 -157
  18. package/dist/example-server/definitions.js +0 -35
  19. package/dist/example-server/server.d.ts +0 -1
  20. package/dist/example-server/server.js +0 -66
  21. package/dist/openapiGenerator.d.ts +0 -2
  22. package/dist/openapiGenerator.js +0 -119
  23. package/dist/router/definition.d.ts +0 -118
  24. package/dist/router/definition.js +0 -80
  25. package/dist/router/router.d.ts +0 -29
  26. package/dist/router/router.js +0 -168
  27. package/dist/src/router/client.d.ts +0 -151
  28. package/dist/src/router/client.js +0 -215
  29. package/dist/src/router/definition.d.ts +0 -121
  30. package/dist/src/router/definition.js +0 -79
  31. package/dist/src/router/handler.d.ts +0 -16
  32. package/dist/src/router/handler.js +0 -170
  33. package/dist/src/router/index.d.ts +0 -5
  34. package/dist/src/router/index.js +0 -22
  35. package/dist/src/router/object-handlers.d.ts +0 -16
  36. package/dist/src/router/object-handlers.js +0 -39
  37. package/dist/src/router/router.d.ts +0 -18
  38. package/dist/src/router/router.js +0 -20
package/src/definition.ts CHANGED
@@ -95,6 +95,8 @@ export function CreateResponses<TInputMap extends Partial<Record<AllowedInputSta
95
95
  });
96
96
  } else if (schemaOrMarker instanceof ZodType) { // It's a Zod schema
97
97
  (builtResult as any)[numericKey] = createSuccessUnifiedResponseSchema(schemaOrMarker);
98
+ } else {
99
+ (builtResult as any)[numericKey] = createSuccessUnifiedResponseSchema(schemaOrMarker);
98
100
  }
99
101
  // Note: If schemaOrMarker is something else, it would be a type error
100
102
  // based on InputSchemaOrMarker, or this runtime check would skip it.
@@ -109,6 +111,35 @@ export function CreateResponses<TInputMap extends Partial<Record<AllowedInputSta
109
111
  return builtResult as CreateResponsesReturnType<TInputMap>;
110
112
  }
111
113
 
114
+ // File upload configuration interface
115
+ export interface FileUploadConfig {
116
+ // Single file upload
117
+ single?: {
118
+ fieldName: string;
119
+ maxSize?: number; // in bytes
120
+ allowedMimeTypes?: string[];
121
+ };
122
+ // Multiple files upload (same field name)
123
+ array?: {
124
+ fieldName: string;
125
+ maxCount?: number;
126
+ maxSize?: number; // in bytes per file
127
+ allowedMimeTypes?: string[];
128
+ };
129
+ // Multiple files upload (different field names)
130
+ fields?: Array<{
131
+ fieldName: string;
132
+ maxCount?: number;
133
+ maxSize?: number; // in bytes per file
134
+ allowedMimeTypes?: string[];
135
+ }>;
136
+ // Any files upload
137
+ any?: {
138
+ maxSize?: number; // in bytes per file
139
+ allowedMimeTypes?: string[];
140
+ };
141
+ }
142
+
112
143
  // Define the structure for a single API route
113
144
  export interface RouteSchema {
114
145
  path: string;
@@ -116,6 +147,7 @@ export interface RouteSchema {
116
147
  params?: ZodTypeAny;
117
148
  query?: ZodTypeAny;
118
149
  body?: ZodTypeAny;
150
+ fileUpload?: FileUploadConfig; // Optional file upload configuration
119
151
  responses: Record<number, ZodTypeAny>; // Maps HTTP status codes to Zod schemas
120
152
  }
121
153
 
@@ -229,3 +261,68 @@ export type ApiClientQuery<
229
261
  > = TDef['endpoints'][TDomain][TRouteName] extends { query: infer Q extends ZodTypeAny }
230
262
  ? z.input<Q> // Use z.input for the type expected by the client to send
231
263
  : undefined;
264
+
265
+ // --- File Upload Validation Schemas ---
266
+
267
+ // Schema for validating uploaded files
268
+ export const fileSchema = z.object({
269
+ fieldname: z.string(),
270
+ originalname: z.string(),
271
+ encoding: z.string(),
272
+ mimetype: z.string(),
273
+ size: z.number(),
274
+ buffer: z.instanceof(Buffer),
275
+ destination: z.string().optional(),
276
+ filename: z.string().optional(),
277
+ path: z.string().optional(),
278
+ stream: z.any().optional(),
279
+ });
280
+
281
+ export type FileType = z.infer<typeof fileSchema>;
282
+
283
+ // Helper function to create file validation schema with constraints
284
+ export function createFileValidationSchema(options?: {
285
+ maxSize?: number;
286
+ allowedMimeTypes?: string[];
287
+ required?: boolean;
288
+ }) {
289
+ let schema: z.ZodTypeAny = fileSchema;
290
+
291
+ if (options?.maxSize) {
292
+ schema = schema.refine(
293
+ (file: any) => file.size <= options.maxSize!,
294
+ { message: `File size must be less than ${options.maxSize} bytes` }
295
+ );
296
+ }
297
+
298
+ if (options?.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
299
+ schema = schema.refine(
300
+ (file: any) => options.allowedMimeTypes!.includes(file.mimetype),
301
+ { message: `File type must be one of: ${options.allowedMimeTypes.join(', ')}` }
302
+ );
303
+ }
304
+
305
+ return options?.required === false ? schema.optional() : schema;
306
+ }
307
+
308
+ // Helper function to create array of files validation schema
309
+ export function createFilesArrayValidationSchema(options?: {
310
+ maxCount?: number;
311
+ maxSize?: number;
312
+ allowedMimeTypes?: string[];
313
+ required?: boolean;
314
+ }) {
315
+ const singleFileSchema = createFileValidationSchema({
316
+ maxSize: options?.maxSize,
317
+ allowedMimeTypes: options?.allowedMimeTypes,
318
+ required: true, // Individual files in array are required
319
+ });
320
+
321
+ let schema = z.array(singleFileSchema);
322
+
323
+ if (options?.maxCount) {
324
+ schema = schema.max(options.maxCount, `Maximum ${options.maxCount} files allowed`);
325
+ }
326
+
327
+ return options?.required === false ? schema.optional() : schema;
328
+ }
package/src/handler.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
- import { ApiDefinitionSchema, RouteSchema, UnifiedError } from "./definition";
2
+ import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig } from "./definition";
3
3
  import { createRouteHandler, TypedRequest, TypedResponse } from "./router";
4
4
  import express from "express";
5
+ import multer from "multer";
5
6
 
6
7
  // A handler entry, now generic over TDef
7
8
  export type SpecificRouteHandler<TDef extends ApiDefinitionSchema> = {
@@ -20,6 +21,151 @@ type EndpointMiddleware = (
20
21
  endpointInfo: { domain: string; routeKey: string }
21
22
  ) => void | Promise<void>;
22
23
 
24
+ // Helper function to create multer middleware based on file upload configuration
25
+ function createFileUploadMiddleware(config: FileUploadConfig): express.RequestHandler {
26
+ // Default multer configuration
27
+ const storage = multer.memoryStorage(); // Store files in memory by default
28
+
29
+ let multerMiddleware: express.RequestHandler;
30
+
31
+ if (config.single) {
32
+ const upload = multer({
33
+ storage,
34
+ limits: {
35
+ fileSize: config.single.maxSize || 10 * 1024 * 1024, // Default 10MB
36
+ },
37
+ fileFilter: (req, file, cb) => {
38
+ if (config.single!.allowedMimeTypes && !config.single!.allowedMimeTypes.includes(file.mimetype)) {
39
+ cb(new Error(`File type ${file.mimetype} not allowed`));
40
+ return;
41
+ }
42
+ cb(null, true);
43
+ }
44
+ });
45
+ multerMiddleware = upload.single(config.single.fieldName);
46
+ } else if (config.array) {
47
+ const upload = multer({
48
+ storage,
49
+ limits: {
50
+ fileSize: config.array.maxSize || 10 * 1024 * 1024, // Default 10MB per file
51
+ files: config.array.maxCount || 10, // Default max 10 files
52
+ },
53
+ fileFilter: (req, file, cb) => {
54
+ if (config.array!.allowedMimeTypes && !config.array!.allowedMimeTypes.includes(file.mimetype)) {
55
+ cb(new Error(`File type ${file.mimetype} not allowed`));
56
+ return;
57
+ }
58
+ cb(null, true);
59
+ }
60
+ });
61
+ multerMiddleware = upload.array(config.array.fieldName, config.array.maxCount);
62
+ } else if (config.fields) {
63
+ const upload = multer({
64
+ storage,
65
+ limits: {
66
+ fileSize: Math.max(...config.fields.map(f => f.maxSize || 10 * 1024 * 1024)), // Use max size from all fields
67
+ },
68
+ fileFilter: (req, file, cb) => {
69
+ const fieldConfig = config.fields!.find(f => f.fieldName === file.fieldname);
70
+ if (fieldConfig?.allowedMimeTypes && !fieldConfig.allowedMimeTypes.includes(file.mimetype)) {
71
+ cb(new Error(`File type ${file.mimetype} not allowed for field ${file.fieldname}`));
72
+ return;
73
+ }
74
+ cb(null, true);
75
+ }
76
+ });
77
+ const fields = config.fields.map(f => ({ name: f.fieldName, maxCount: f.maxCount || 1 }));
78
+ multerMiddleware = upload.fields(fields);
79
+ } else if (config.any) {
80
+ const upload = multer({
81
+ storage,
82
+ limits: {
83
+ fileSize: config.any.maxSize || 10 * 1024 * 1024, // Default 10MB per file
84
+ },
85
+ fileFilter: (req, file, cb) => {
86
+ if (config.any!.allowedMimeTypes && !config.any!.allowedMimeTypes.includes(file.mimetype)) {
87
+ cb(new Error(`File type ${file.mimetype} not allowed`));
88
+ return;
89
+ }
90
+ cb(null, true);
91
+ }
92
+ });
93
+ multerMiddleware = upload.any();
94
+ } else {
95
+ // Fallback - should not reach here if config is valid
96
+ throw new Error('Invalid file upload configuration');
97
+ }
98
+
99
+ // Wrap multer middleware with error handling to format errors as 422 JSON responses
100
+ return (req: express.Request, res: express.Response, next: express.NextFunction) => {
101
+ multerMiddleware(req, res, (error) => {
102
+ if (error) {
103
+ // Convert multer errors to UnifiedError format
104
+ const mappedErrors: UnifiedError = [];
105
+
106
+ if (error instanceof multer.MulterError) {
107
+ let errorMessage = error.message;
108
+ let fieldName = 'file';
109
+
110
+ switch (error.code) {
111
+ case 'LIMIT_FILE_SIZE':
112
+ errorMessage = 'File size exceeds the allowed limit';
113
+ break;
114
+ case 'LIMIT_FILE_COUNT':
115
+ errorMessage = 'Too many files uploaded';
116
+ break;
117
+ case 'LIMIT_UNEXPECTED_FILE':
118
+ errorMessage = `Unexpected field: ${error.field}`;
119
+ fieldName = error.field || 'file';
120
+ break;
121
+ case 'LIMIT_FIELD_KEY':
122
+ errorMessage = 'Field name too long';
123
+ break;
124
+ case 'LIMIT_FIELD_VALUE':
125
+ errorMessage = 'Field value too long';
126
+ break;
127
+ case 'LIMIT_FIELD_COUNT':
128
+ errorMessage = 'Too many fields';
129
+ break;
130
+ case 'LIMIT_PART_COUNT':
131
+ errorMessage = 'Too many parts';
132
+ break;
133
+ default:
134
+ errorMessage = error.message || 'File upload error';
135
+ }
136
+
137
+ mappedErrors.push({
138
+ field: fieldName,
139
+ message: errorMessage,
140
+ type: 'body',
141
+ });
142
+ } else if (error instanceof Error) {
143
+ // Handle custom errors from fileFilter
144
+ mappedErrors.push({
145
+ field: 'file',
146
+ message: error.message,
147
+ type: 'body',
148
+ });
149
+ } else {
150
+ mappedErrors.push({
151
+ field: 'file',
152
+ message: 'File upload error',
153
+ type: 'body',
154
+ });
155
+ }
156
+
157
+ // Send 422 response with structured error format
158
+ res.status(422).json({
159
+ data: null,
160
+ error: mappedErrors
161
+ });
162
+ } else {
163
+ next();
164
+ }
165
+ });
166
+ };
167
+ }
168
+
23
169
  // Register route handlers with Express, now generic over TDef
24
170
  export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
25
171
  app: express.Express,
@@ -192,6 +338,18 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
192
338
 
193
339
  // Create middleware wrappers that include endpoint information
194
340
  const middlewareWrappers: express.RequestHandler[] = [];
341
+
342
+ // Add file upload middleware if configured
343
+ if (routeDefinition.fileUpload) {
344
+ try {
345
+ const fileUploadMiddleware = createFileUploadMiddleware(routeDefinition.fileUpload);
346
+ middlewareWrappers.push(fileUploadMiddleware);
347
+ } catch (error) {
348
+ console.error(`Error creating file upload middleware for ${currentDomain}.${currentRouteKey}:`, error);
349
+ return; // Skip this route if file upload middleware creation fails
350
+ }
351
+ }
352
+
195
353
  if (middlewares && middlewares.length > 0) {
196
354
  middlewares.forEach(middleware => {
197
355
  const wrappedMiddleware: express.RequestHandler = async (req, res, next) => {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ApiClient, FetchHttpClientAdapter } from './client';
2
2
  export { CreateApiDefinition, CreateResponses } from './definition';
3
3
  export { RegisterHandlers, EndpointMiddleware } from './object-handlers';
4
+ export { File as UploadedFile } from './router';
4
5
  export { z as ZodSchema } from 'zod';
package/src/router.ts CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  InferDataFromUnifiedResponse,
8
8
  } from './definition';
9
9
 
10
+ // Define the file type based on Express.Multer namespace
11
+ export type File = Express.Multer.File;
12
+
10
13
  // Typed Request for Express handlers, now generic over TDef
11
14
  export type TypedRequest<
12
15
  TDef extends ApiDefinitionSchema,
@@ -17,7 +20,11 @@ export type TypedRequest<
17
20
  ReqBody extends ApiBody<TDef, TDomain, TRouteKey> = ApiBody<TDef, TDomain, TRouteKey>,
18
21
  Q extends ApiQuery<TDef, TDomain, TRouteKey> = ApiQuery<TDef, TDomain, TRouteKey>,
19
22
  L extends Record<string, any> = Record<string, any>
20
- > = express.Request<P, any, ReqBody, Q, L>
23
+ > = express.Request<P, any, ReqBody, Q, L> & {
24
+ // Add file upload support
25
+ file?: File;
26
+ files?: File[] | { [fieldname: string]: File[] };
27
+ }
21
28
 
22
29
  // --- Enhanced TypedResponse with res.respond, now generic over TDef ---
23
30
 
@@ -1,147 +0,0 @@
1
- import { type ApiDefinitionSchema as BaseApiDefinitionSchema, // Renamed for clarity
2
- type ApiRouteKey, type ApiClientParams, type ApiClientQuery, type ApiClientBody, type RouteSchema, type UnifiedError, type InferDataFromUnifiedResponse } from "./router/definition";
3
- import { type ZodTypeAny } from 'zod';
4
- /**
5
- * Options for an HTTP request made by an adapter.
6
- */
7
- export interface HttpRequestOptions {
8
- method: RouteSchema['method'];
9
- headers?: Record<string, string>;
10
- body?: string | FormData;
11
- }
12
- /**
13
- * Represents an HTTP response from an adapter.
14
- * @template T The expected type of the JSON body.
15
- */
16
- export interface HttpResponse<T = any> {
17
- status: number;
18
- headers: Headers;
19
- json(): Promise<T>;
20
- text(): Promise<string>;
21
- /**
22
- * Gets the underlying raw response object from the adapter (e.g., Fetch API's Response object).
23
- * The type of this object depends on the adapter implementation.
24
- */
25
- getRawResponse(): any;
26
- }
27
- /**
28
- * Interface for an HTTP client adapter.
29
- * Allows swapping out the underlying HTTP request mechanism (e.g., fetch, axios).
30
- */
31
- export interface HttpClientAdapter {
32
- /**
33
- * Makes an HTTP request.
34
- * @template T The expected type of the JSON response body.
35
- * @param url The URL to request.
36
- * @param options The request options.
37
- * @returns A promise that resolves to an HttpResponse.
38
- */
39
- request<T = any>(url: string, options: HttpRequestOptions): Promise<HttpResponse<T>>;
40
- }
41
- /**
42
- * An HttpClientAdapter implementation that uses the native Fetch API.
43
- */
44
- export declare class FetchHttpClientAdapter implements HttpClientAdapter {
45
- request<T = any>(url: string, options: HttpRequestOptions): Promise<HttpResponse<T>>;
46
- }
47
- type GetRoute<TActualDef extends BaseApiDefinitionSchema, TDomain extends keyof TActualDef, K extends ApiRouteKey<TActualDef, TDomain>> = TActualDef[TDomain][K] extends infer Rte ? Rte extends RouteSchema ? Rte : never : never;
48
- type GetResponses<Rte extends RouteSchema> = Rte['responses'];
49
- /**
50
- * Discriminated union type for the result of callApi.
51
- * @template TDef The specific ApiDefinition structure being used.
52
- * @template TDomain The domain (controller) of the API.
53
- * @template TRouteKey The key of the route within the domain.
54
- */
55
- type ApiCallResultPayload<S_STATUS_NUM extends number, ActualSchema extends ZodTypeAny> = S_STATUS_NUM extends 422 ? {
56
- status: S_STATUS_NUM;
57
- error: UnifiedError;
58
- rawResponse: any;
59
- data?: undefined;
60
- } : S_STATUS_NUM extends 204 ? {
61
- status: S_STATUS_NUM;
62
- data: void;
63
- rawResponse: any;
64
- error?: undefined;
65
- } : {
66
- status: S_STATUS_NUM;
67
- data: InferDataFromUnifiedResponse<ActualSchema>;
68
- rawResponse: any;
69
- error?: undefined;
70
- };
71
- export type ApiCallResult<TActualDef extends BaseApiDefinitionSchema, TDomain extends keyof TActualDef, TRouteKey extends ApiRouteKey<TActualDef, TDomain>, CurrentRoute extends RouteSchema = GetRoute<TActualDef, TDomain, TRouteKey>, ResponsesMap = GetResponses<CurrentRoute>> = ResponsesMap extends Record<any, ZodTypeAny> ? {
72
- [S_STATUS_NUM in keyof ResponsesMap]: S_STATUS_NUM extends number ? ResponsesMap[S_STATUS_NUM] extends infer ActualSchema extends ZodTypeAny ? ApiCallResultPayload<S_STATUS_NUM, ActualSchema> : never : never;
73
- }[keyof ResponsesMap] : never;
74
- /**
75
- * Options for the callApi method.
76
- * @template TDef The specific ApiDefinition structure being used.
77
- * @template TDomainParam The domain (controller) of the API.
78
- * @template TRouteKeyParam The key of the route within the domain.
79
- */
80
- export type CallApiOptions<TActualDef extends BaseApiDefinitionSchema, // Made generic over TActualDef
81
- TDomainParam extends keyof TActualDef, TRouteKeyParam extends ApiRouteKey<TActualDef, TDomainParam>> = {
82
- params?: ApiClientParams<TActualDef, TDomainParam, TRouteKeyParam>;
83
- query?: ApiClientQuery<TActualDef, TDomainParam, TRouteKeyParam>;
84
- body?: ApiClientBody<TActualDef, TDomainParam, TRouteKeyParam>;
85
- headers?: Record<string, string>;
86
- };
87
- /**
88
- * A client for making API calls defined by an ApiDefinition.
89
- * It uses an HttpClientAdapter for making actual HTTP requests and supports persistent headers.
90
- */
91
- export declare class ApiClient<TActualDef extends BaseApiDefinitionSchema> {
92
- private baseUrl;
93
- private apiDefinitionObject;
94
- private adapter;
95
- private persistentHeaders;
96
- /**
97
- * Creates an instance of ApiClient.
98
- * @param baseUrl The base URL for all API calls (e.g., 'http://localhost:3001').
99
- * @param apiDefinitionObject The API definition object.
100
- * @param adapter An instance of HttpClientAdapter to use for requests.
101
- */
102
- constructor(baseUrl: string, apiDefinitionObject: TActualDef, // Parameter uses TActualDef
103
- adapter: HttpClientAdapter);
104
- /**
105
- * Sets a persistent header that will be included in all subsequent API calls.
106
- * If the header already exists, its value will be updated.
107
- * @param name The name of the header.
108
- * @param value The value of the header.
109
- */
110
- setHeader(name: string, value: string): void;
111
- /**
112
- * Gets the value of a persistent header.
113
- * @param name The name of the header.
114
- * @returns The value of the header, or undefined if not set.
115
- */
116
- getHeader(name: string): string | undefined;
117
- /**
118
- * Removes a persistent header.
119
- * @param name The name of the header to remove.
120
- */
121
- removeHeader(name: string): void;
122
- /**
123
- * Clears all persistent headers.
124
- */
125
- clearHeaders(): void;
126
- /**
127
- * Makes an API call to a specified domain and route.
128
- * @template TDomain The domain (controller) of the API.
129
- * @template TRouteKey The key of the route within the domain.
130
- * @template TInferredHandlers A type inferred from the handlers object, ensuring all defined statuses are handled.
131
- * @param domain The API domain (e.g., 'user').
132
- * @param routeKey The API route key (e.g., 'getUsers').
133
- * @param callData Optional parameters, query, body, and headers for the request.
134
- * @param handlers An object where keys are status codes and values are handler functions for those statuses.
135
- * @returns A promise that resolves to the return value of the executed handler.
136
- * @throws Error if the route configuration is invalid, a network error occurs, an unhandled status code is received, or JSON parsing fails.
137
- */
138
- callApi<TDomain extends keyof TActualDef, TRouteKey extends ApiRouteKey<TActualDef, TDomain>, TInferredHandlers extends {
139
- [KStatus in ApiCallResult<TActualDef, TDomain, TRouteKey>['status']]: (payload: Extract<ApiCallResult<TActualDef, TDomain, TRouteKey>, {
140
- status: KStatus;
141
- }>) => any;
142
- }>(domain: TDomain, routeKey: TRouteKey, callData: CallApiOptions<TActualDef, TDomain, TRouteKey> | undefined, // Uses TActualDef
143
- handlers: TInferredHandlers): Promise<{
144
- [SKey in keyof TInferredHandlers]: TInferredHandlers[SKey] extends (...args: any[]) => infer R ? R : never;
145
- }[keyof TInferredHandlers]>;
146
- }
147
- export {};
package/dist/apiClient.js DELETED
@@ -1,206 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiClient = exports.FetchHttpClientAdapter = void 0;
4
- // --- Fetch Implementation of the Adapter ---
5
- /**
6
- * An HttpClientAdapter implementation that uses the native Fetch API.
7
- */
8
- class FetchHttpClientAdapter {
9
- async request(url, options) {
10
- const fetchOptions = {
11
- method: options.method,
12
- headers: options.headers,
13
- // Note: `credentials` (e.g., 'include' for cookies) is not set by default.
14
- // It can be configured by extending this adapter or by managing cookies via the 'Cookie' header.
15
- };
16
- if (options.body !== undefined) {
17
- fetchOptions.body = options.body;
18
- }
19
- const nativeFetchResponse = await fetch(url, fetchOptions);
20
- return {
21
- status: nativeFetchResponse.status,
22
- headers: nativeFetchResponse.headers,
23
- json: () => nativeFetchResponse.json(),
24
- text: () => nativeFetchResponse.text(),
25
- getRawResponse: () => nativeFetchResponse,
26
- };
27
- }
28
- }
29
- exports.FetchHttpClientAdapter = FetchHttpClientAdapter;
30
- // --- API Client Class ---
31
- /**
32
- * A client for making API calls defined by an ApiDefinition.
33
- * It uses an HttpClientAdapter for making actual HTTP requests and supports persistent headers.
34
- */
35
- class ApiClient {
36
- baseUrl;
37
- apiDefinitionObject; // Uses generic type TActualDef
38
- adapter;
39
- persistentHeaders = {};
40
- /**
41
- * Creates an instance of ApiClient.
42
- * @param baseUrl The base URL for all API calls (e.g., 'http://localhost:3001').
43
- * @param apiDefinitionObject The API definition object.
44
- * @param adapter An instance of HttpClientAdapter to use for requests.
45
- */
46
- constructor(baseUrl, apiDefinitionObject, // Parameter uses TActualDef
47
- adapter) {
48
- this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
49
- this.apiDefinitionObject = apiDefinitionObject;
50
- this.adapter = adapter;
51
- }
52
- /**
53
- * Sets a persistent header that will be included in all subsequent API calls.
54
- * If the header already exists, its value will be updated.
55
- * @param name The name of the header.
56
- * @param value The value of the header.
57
- */
58
- setHeader(name, value) {
59
- this.persistentHeaders[name] = value;
60
- }
61
- /**
62
- * Gets the value of a persistent header.
63
- * @param name The name of the header.
64
- * @returns The value of the header, or undefined if not set.
65
- */
66
- getHeader(name) {
67
- return this.persistentHeaders[name];
68
- }
69
- /**
70
- * Removes a persistent header.
71
- * @param name The name of the header to remove.
72
- */
73
- removeHeader(name) {
74
- delete this.persistentHeaders[name];
75
- }
76
- /**
77
- * Clears all persistent headers.
78
- */
79
- clearHeaders() {
80
- this.persistentHeaders = {};
81
- }
82
- /**
83
- * Makes an API call to a specified domain and route.
84
- * @template TDomain The domain (controller) of the API.
85
- * @template TRouteKey The key of the route within the domain.
86
- * @template TInferredHandlers A type inferred from the handlers object, ensuring all defined statuses are handled.
87
- * @param domain The API domain (e.g., 'user').
88
- * @param routeKey The API route key (e.g., 'getUsers').
89
- * @param callData Optional parameters, query, body, and headers for the request.
90
- * @param handlers An object where keys are status codes and values are handler functions for those statuses.
91
- * @returns A promise that resolves to the return value of the executed handler.
92
- * @throws Error if the route configuration is invalid, a network error occurs, an unhandled status code is received, or JSON parsing fails.
93
- */
94
- async callApi(domain, routeKey, callData, // Uses TActualDef
95
- handlers) {
96
- const routeInfo = this.apiDefinitionObject[domain][routeKey]; // Accessing from TActualDef instance
97
- if (!routeInfo || typeof routeInfo.path !== 'string') {
98
- throw new Error(`API route configuration ${String(domain)}.${String(routeKey)} not found or invalid.`);
99
- }
100
- let urlPath = routeInfo.path;
101
- if (callData?.params) {
102
- const params = callData.params;
103
- for (const key in params) {
104
- if (Object.prototype.hasOwnProperty.call(params, key) && params[key] !== undefined) {
105
- urlPath = urlPath.replace(`:${key}`, String(params[key]));
106
- }
107
- }
108
- }
109
- const url = new URL(this.baseUrl + urlPath);
110
- if (callData?.query) {
111
- const queryParams = callData.query;
112
- for (const key in queryParams) {
113
- if (Object.prototype.hasOwnProperty.call(queryParams, key) && queryParams[key] !== undefined) {
114
- url.searchParams.append(key, String(queryParams[key]));
115
- }
116
- }
117
- }
118
- const requestHeaders = {
119
- ...this.persistentHeaders,
120
- 'Content-Type': 'application/json',
121
- ...(callData?.headers || {}),
122
- };
123
- const adapterRequestOptions = {
124
- method: routeInfo.method,
125
- headers: requestHeaders,
126
- };
127
- if (routeInfo.method !== 'GET' && routeInfo.method !== 'HEAD' && callData?.body !== undefined) {
128
- adapterRequestOptions.body = JSON.stringify(callData.body);
129
- }
130
- let adapterResponse;
131
- try {
132
- adapterResponse = await this.adapter.request(url.toString(), adapterRequestOptions);
133
- }
134
- catch (networkError) {
135
- const errorMessage = networkError instanceof Error ? networkError.message : `Unknown network error calling API ${String(domain)}.${String(routeKey)}`;
136
- console.error(`Network error for ${String(domain)}.${String(routeKey)}:`, networkError);
137
- throw new Error(`Network error: ${errorMessage}`);
138
- }
139
- const runtimeStatus = adapterResponse.status;
140
- const definedStatusCodes = Object.keys(routeInfo.responses).map(Number);
141
- if (!definedStatusCodes.includes(runtimeStatus)) {
142
- const responseText = await adapterResponse.text().catch(() => "Could not read response text.");
143
- const errorMsg = `API ${String(domain)}.${String(routeKey)}: Received unhandled status code ${runtimeStatus}. Expected one of: ${definedStatusCodes.join(', ')}. Response: ${responseText}`;
144
- console.error(errorMsg);
145
- throw new Error(errorMsg);
146
- }
147
- const currentStatusLiteral = runtimeStatus;
148
- // apiResultPayload now uses ApiCallResult with TActualDef
149
- let apiResultPayload;
150
- if (currentStatusLiteral === 204) {
151
- apiResultPayload = {
152
- status: 204,
153
- data: undefined,
154
- rawResponse: adapterResponse.getRawResponse(),
155
- };
156
- }
157
- else {
158
- let responseBodyJson;
159
- const contentType = adapterResponse.headers.get("content-type");
160
- if (contentType && contentType.includes("application/json")) {
161
- try {
162
- responseBodyJson = await adapterResponse.json();
163
- }
164
- catch (e) {
165
- const parseErrorMsg = `API ${String(domain)}.${String(routeKey)}: Failed to parse JSON for status ${currentStatusLiteral}. Error: ${e instanceof Error ? e.message : String(e)}`;
166
- console.error(parseErrorMsg, adapterResponse.getRawResponse());
167
- throw new Error(parseErrorMsg);
168
- }
169
- }
170
- else if (runtimeStatus >= 400) { // Handle non-JSON error responses
171
- const responseText = await adapterResponse.text().catch(() => "Could not read response text.");
172
- if (currentStatusLiteral === 422) { // Try to conform to UnifiedError for 422
173
- responseBodyJson = {
174
- error: [{ field: 'general', type: 'general', message: `Non-JSON error response for 422: ${responseText}` }] // Adjusted: type to 'general'
175
- };
176
- }
177
- else { // For other non-JSON errors, data will likely be undefined. Log a warning.
178
- console.warn(`API ${String(domain)}.${String(routeKey)}: Received non-JSON response for status ${currentStatusLiteral}. Response: ${responseText}`);
179
- // responseBodyJson remains undefined or as is, data extraction below will handle it.
180
- }
181
- }
182
- if (currentStatusLiteral === 422) {
183
- const errorData = responseBodyJson?.error || [{ field: 'general', type: 'general', message: `HTTP error 422: ${await adapterResponse.text().catch(() => 'Unknown error text')}` }];
184
- // Assign directly to apiResultPayload, relying on its type ApiCallResult<TActualDef, TDomain, TRouteKey>
185
- // to correctly match the 422 variant.
186
- apiResultPayload = {
187
- status: 422,
188
- error: errorData,
189
- rawResponse: adapterResponse.getRawResponse(),
190
- }; // Force cast via unknown
191
- }
192
- else {
193
- // Assuming responseBodyJson is an object like { data: <actual_payload> }
194
- // as per backend contract for non-204/non-422 responses.
195
- apiResultPayload = {
196
- status: currentStatusLiteral,
197
- data: responseBodyJson.data,
198
- rawResponse: adapterResponse.getRawResponse(),
199
- };
200
- }
201
- }
202
- const handler = handlers[apiResultPayload.status];
203
- return handler(apiResultPayload); // Reverting to `as any` as TS struggles with direct narrowing here
204
- }
205
- }
206
- exports.ApiClient = ApiClient;