diff --git a/.gitignore b/.gitignore index 243bfa3..88ef63a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ build/Release node_modules/ jspm_packages/ +# Lock files (project uses pnpm) +package-lock.json +yarn.lock + # Snowpack dependency directory (https://snowpack.dev/) web_modules/ diff --git a/README.md b/README.md index 1b6f9ad..df39b2a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - **🎯 Type-Safe**: Full TypeScript support with strict type inference - **📊 Standard Schema**: Native support for Zod, Valibot, and other Standard Schema compliant libraries - **🔍 Request/Response Validation**: Runtime validation with detailed error messages +- **💡 Suggestions Only Mode**: Skip validation for performance while keeping type safety - **🏗️ Builder Pattern**: Intuitive API inspired by Hono and Octokit - **📋 Structured Response**: Rich response metadata (headers, status, URL) with `~raw` access - **⚡ Lightweight**: Zero dependencies (except peer dependencies) @@ -262,6 +263,7 @@ interface TypeFetcherConfig { readonly headers?: Record; readonly timeout?: number; readonly fetch?: typeof globalThis.fetch; // Custom fetch implementation + readonly skipValidation?: boolean; // Skip validation globally (schemas still provide type inference) } ``` @@ -285,10 +287,11 @@ Registers a new endpoint with optional schema validation. **Schema Object:** ```typescript interface EndpointSchema { - readonly params?: StandardSchemaV1; // Path parameters - readonly query?: StandardSchemaV1; // Query parameters - readonly body?: StandardSchemaV1; // Request body - readonly response?: StandardSchemaV1; // Response validation + readonly params?: StandardSchemaV1; // Path parameters + readonly query?: StandardSchemaV1; // Query parameters + readonly body?: StandardSchemaV1; // Request body + readonly response?: StandardSchemaV1; // Response validation + readonly skipValidation?: boolean; // Skip validation for this endpoint (overrides global setting) } ``` @@ -485,6 +488,61 @@ const response = await api.request("GET /items/{id}", { }); ``` +### Suggestions Only Mode (Skip Validation) + +For production environments where validation performance is critical or where API response changes shouldn't break the application, you can skip runtime validation while still maintaining TypeScript type safety: + +```typescript +// Skip validation globally - schemas still provide type inference +const client = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: true // All endpoints skip validation by default +}); + +const api = client.addEndpoint("GET", "/users/{id}", { + response: z.object({ + id: z.number(), + name: z.string(), + }) +}); + +// No runtime validation, but response.data is still typed as { id: number; name: string } +const response = await api.request("GET /users/{id}", { + params: { id: "123" } +}); +``` + +You can also skip validation per-endpoint (overrides global setting): + +```typescript +const client = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: false // Validation enabled by default +}); + +const api = client + .addEndpoint("GET", "/users", { + response: z.array(UserSchema), + skipValidation: true // Skip validation for this endpoint only + }) + .addEndpoint("POST", "/users", { + body: CreateUserSchema, + response: UserSchema + // This endpoint will validate because global default is false + }); +``` + +**Benefits:** +- **Performance**: Skip validation overhead in production +- **Resilience**: Avoid errors when API responses change unexpectedly +- **Type Safety**: TypeScript types are still inferred from schemas +- **Flexibility**: Configure globally or per-endpoint + +**Use Cases:** +- High-performance production APIs where validation is done server-side +- Legacy APIs with evolving schemas +- Development environments where you want types but not strict validation + ## 🌟 Why TypeFetcher? ### Standard Schema Native diff --git a/examples/skip-validation-usage.ts b/examples/skip-validation-usage.ts new file mode 100644 index 0000000..c6c964d --- /dev/null +++ b/examples/skip-validation-usage.ts @@ -0,0 +1,279 @@ +/** + * TypeFetcher Skip Validation (Suggestions Only) Usage Examples + * + * This file demonstrates how to use schemas for type inference only, + * without performing runtime validation. This is useful for: + * - Performance-critical production environments + * - APIs where validation is handled server-side + * - Legacy APIs with evolving schemas + */ + +import { TypeFetcher } from "../dist"; +import { z } from "zod"; + +/** + * Global skip validation example + * All endpoints will skip validation by default + */ +function globalSkipValidationExample() { + // Define schemas for type inference + const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + + const CreateUserSchema = z.object({ + name: z.string(), + email: z.string().email(), + }); + + // Create client with global skipValidation + const client = new TypeFetcher({ + baseURL: "https://jsonplaceholder.typicode.com", + skipValidation: true, // Skip validation for all endpoints + }); + + // Register endpoints with schemas + // Schemas provide TypeScript types but no runtime validation + const api = client + .addEndpoint("GET", "/users", { + response: z.array(UserSchema), + }) + .addEndpoint("GET", "/users/{id}", { + params: z.object({ id: z.string() }), + response: UserSchema, + }) + .addEndpoint("POST", "/users", { + body: CreateUserSchema, + response: UserSchema, + }); + + return { + async getUsers() { + // Response is typed as User[] but not validated at runtime + const response = await api.request("GET /users"); + // response.data has type User[] from schema + return response.data; + }, + + async getUser(id: string) { + // Params are typed but not validated at runtime + const response = await api.request("GET /users/{id}", { + params: { id }, // Type-safe but no validation + }); + return response.data; + }, + + async createUser(userData: { name: string; email: string }) { + // Body is typed but not validated at runtime + const response = await api.request("POST /users", { + body: userData, // Type-safe but no validation + }); + return response.data; + }, + }; +} + +/** + * Per-endpoint skip validation example + * Fine-grained control over which endpoints skip validation + */ +function perEndpointSkipValidationExample() { + const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + + // Create client with validation enabled by default + const client = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: false, // Default: validate + }); + + const api = client + .addEndpoint("GET", "/users", { + response: z.array(UserSchema), + skipValidation: true, // Skip validation for this endpoint + }) + .addEndpoint("POST", "/users", { + body: z.object({ + name: z.string(), + email: z.string().email(), + }), + response: UserSchema, + // This endpoint WILL validate (uses global default: false) + }); + + return { + async getUsers() { + // No validation - even if response doesn't match schema + const response = await api.request("GET /users"); + return response.data; + }, + + async createUser(userData: { name: string; email: string }) { + // This WILL validate - throws ValidationError if invalid + const response = await api.request("POST /users", { + body: userData, + }); + return response.data; + }, + }; +} + +/** + * Mixed validation example + * Use validation for critical endpoints, skip for others + */ +function mixedValidationExample() { + const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + + const client = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: true, // Skip validation by default + }); + + const api = client + // Analytics endpoint - skip validation for performance + .addEndpoint("POST", "/analytics/track", { + body: z.object({ + event: z.string(), + data: z.record(z.unknown()), + }), + skipValidation: true, + }) + // User creation - validate for data integrity + .addEndpoint("POST", "/users", { + body: z.object({ + name: z.string().min(1), + email: z.string().email(), + }), + response: UserSchema, + skipValidation: false, // Override global setting + }) + // Profile fetch - skip validation for performance + .addEndpoint("GET", "/profile", { + response: UserSchema, + // Uses global default: skipValidation: true + }); + + return { + async trackEvent(event: string, data: Record) { + // High-performance, no validation overhead + await api.request("POST /analytics/track", { + body: { event, data }, + }); + }, + + async createUser(name: string, email: string) { + // Validated for data integrity + const response = await api.request("POST /users", { + body: { name, email }, + }); + return response.data; + }, + + async getProfile() { + // Fast fetch without validation + const response = await api.request("GET /profile"); + return response.data; + }, + }; +} + +/** + * Production environment example + * Skip validation in production, enable in development + */ +function environmentBasedValidationExample() { + const isProd = process.env.NODE_ENV === "production"; + + const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + + // Skip validation in production for performance + // Enable validation in development for debugging + const client = new TypeFetcher({ + baseURL: process.env.API_BASE_URL || "https://api.example.com", + skipValidation: isProd, // Skip in prod, validate in dev + }); + + const api = client + .addEndpoint("GET", "/users", { + response: z.array(UserSchema), + }) + .addEndpoint("GET", "/users/{id}", { + params: z.object({ id: z.string() }), + response: UserSchema, + }); + + console.log( + isProd ? "Running in production mode (validation skipped)" : "Running in development mode (validation enabled)" + ); + + return api; +} + +/** + * Legacy API example + * Skip validation for evolving API responses + */ +function legacyApiExample() { + // Define the "ideal" schema, but skip validation + // because the legacy API might return extra or missing fields + const LegacyUserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().optional(), // Email might not always be present + // Legacy API might return other unexpected fields + }); + + const client = new TypeFetcher({ + baseURL: "https://legacy-api.example.com", + skipValidation: true, // Don't break on unexpected fields + }); + + const api = client.addEndpoint("GET", "/users/{id}", { + params: z.object({ id: z.string() }), + response: LegacyUserSchema, + }); + + return { + async getUser(id: string) { + // Won't throw error even if response has extra fields + // or missing optional fields + const response = await api.request("GET /users/{id}", { + params: { id }, + }); + + // Still get TypeScript type safety + const user = response.data; + console.log(`User ID: ${user.id}, Name: ${user.name}`); + + // Handle optional fields safely + if (user.email) { + console.log(`Email: ${user.email}`); + } + + return user; + }, + }; +} + +// Export usage examples +export { + globalSkipValidationExample, + perEndpointSkipValidationExample, + mixedValidationExample, + environmentBasedValidationExample, + legacyApiExample, +}; diff --git a/src/client.test.ts b/src/client.test.ts index a613c98..dce3613 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -544,4 +544,168 @@ describe("TypeFetcher with ~raw Response", () => { }) ); }); + + test("should skip validation when skipValidation is set globally", async () => { + const responseData = { id: "not-a-number", name: "John" }; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + const clientWithSkip = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: true, + }); + + const responseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + const client = clientWithSkip.addEndpoint("GET", "/users/{id}", { + response: responseSchema, + }); + + // Should not throw even though response doesn't match schema + const result = await client.request("GET /users/{id}", { + params: { id: "1" }, + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("should skip validation when skipValidation is set per-endpoint", async () => { + const responseData = { id: "not-a-number", name: "John" }; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + const responseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + const client = fetcher.addEndpoint("GET", "/users/{id}", { + response: responseSchema, + skipValidation: true, + }); + + // Should not throw even though response doesn't match schema + const result = await client.request("GET /users/{id}", { + params: { id: "1" }, + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("should skip request body validation when skipValidation is enabled", async () => { + const requestData = { name: 123 }; // Invalid: name should be string + const responseData = { id: 2, name: "123" }; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + const bodySchema = z.object({ + name: z.string(), + }); + + const client = fetcher.addEndpoint("POST", "/users", { + body: bodySchema, + skipValidation: true, + }); + + // Should not throw even though body doesn't match schema + const result = await client.request("POST /users", { + // biome-ignore lint/suspicious/noExplicitAny: Test requires invalid input type + body: requestData as any, + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("should skip path parameters validation when skipValidation is enabled", async () => { + const responseData = { id: 1, name: "John" }; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + const pathSchema = z.object({ + id: z.string().min(10), // Requires at least 10 characters + }); + + const client = fetcher.addEndpoint("GET", "/users/{id}", { + params: pathSchema, + skipValidation: true, + }); + + // Should not throw even though id is too short + const result = await client.request("GET /users/{id}", { + params: { id: "1" }, // Only 1 character + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("should skip query parameters validation when skipValidation is enabled", async () => { + const responseData = [{ id: 1, name: "John" }]; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + const querySchema = z.object({ + page: z.string().min(2), + }); + + const client = fetcher.addEndpoint("GET", "/users", { + query: querySchema, + skipValidation: true, + }); + + // Should not throw even though page is too short + const result = await client.request("GET /users", { + query: { page: "1" }, // Only 1 character, schema requires 2 + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("endpoint-level skipValidation should override global setting", async () => { + const responseData = { id: "not-a-number", name: "John" }; + mockFetch.mockResolvedValueOnce(createMockResponse(responseData)); + + // Global skipValidation is false + const clientWithoutSkip = new TypeFetcher({ + baseURL: "https://api.example.com", + skipValidation: false, + }); + + const responseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + // But endpoint-level is true + const client = clientWithoutSkip.addEndpoint("GET", "/users/{id}", { + response: responseSchema, + skipValidation: true, + }); + + // Should not throw because endpoint-level overrides global + const result = await client.request("GET /users/{id}", { + params: { id: "1" }, + }); + + expect(result.data).toMatchObject(responseData); + }); + + test("should still validate when skipValidation is false", async () => { + const invalidResponse = { id: "not-a-number", name: "John" }; + mockFetch.mockResolvedValueOnce(createMockResponse(invalidResponse)); + + const responseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + const client = fetcher.addEndpoint("GET", "/users/{id}", { + response: responseSchema, + skipValidation: false, + }); + + // Should throw because validation is explicitly enabled + await expect( + client.request("GET /users/{id}", { + params: { id: "1" }, + }) + ).rejects.toThrow("Response validation failed"); + }); }); diff --git a/src/client.ts b/src/client.ts index fc6e7f4..0d477c6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -157,10 +157,13 @@ export class TypeFetcher> { signal, } = (options || {}) as RequestOptionsForEndpoint; + // Determine if validation should be skipped (endpoint-level overrides global) + const skipValidation = endpoint.schema?.skipValidation ?? this.config.skipValidation ?? false; + // Validate and replace path parameters let finalPath = pathTemplate; if (params) { - if (endpoint.schema?.params) { + if (endpoint.schema?.params && !skipValidation) { const validation = validateSync(endpoint.schema.params, params); if (!validation.success) { throw new ValidationError(validation.issues || [], "Path parameters validation failed"); @@ -174,7 +177,7 @@ export class TypeFetcher> { // Validate query parameters let validatedQuery: unknown; if (query) { - if (endpoint.schema?.query) { + if (endpoint.schema?.query && !skipValidation) { const validation = validateSync(endpoint.schema.query, query); if (!validation.success) { throw new ValidationError(validation.issues || [], "Query parameters validation failed"); @@ -188,7 +191,7 @@ export class TypeFetcher> { // Validate request body let validatedBody: unknown; if (body) { - if (endpoint.schema?.body) { + if (endpoint.schema?.body && !skipValidation) { const validation = validateSync(endpoint.schema.body, body); if (!validation.success) { throw new ValidationError(validation.issues || [], "Request body validation failed"); @@ -250,7 +253,7 @@ export class TypeFetcher> { // Validate response data against schema let finalData: unknown = responseData; - if (endpoint.schema?.response) { + if (endpoint.schema?.response && !skipValidation) { const validation = validateSync(endpoint.schema.response, responseData); if (!validation.success) { throw new ValidationError(validation.issues || [], "Response validation failed"); diff --git a/src/types.ts b/src/types.ts index 70c8943..136cd59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ export interface EndpointSchema { readonly query?: StandardSchemaV1; readonly body?: StandardSchemaV1; readonly response?: StandardSchemaV1; + readonly skipValidation?: boolean; } /** @@ -75,6 +76,7 @@ export interface TypeFetcherConfig { readonly headers?: Record; readonly timeout?: number; readonly fetch?: typeof globalThis.fetch; + readonly skipValidation?: boolean; } /**