UNPKG

zodsei

Version:

Contract-first type-safe HTTP client with Zod validation

840 lines (828 loc) 23.4 kB
// src/validation.ts import { z } from "zod"; // src/errors.ts var ZodseiError = class extends Error { constructor(message, code) { super(message); this.code = code; this.name = "ZodseiError"; } }; var ValidationError = class _ValidationError extends ZodseiError { constructor(message, issues, type = "request") { super(message, "VALIDATION_ERROR"); this.issues = issues; this.type = type; this.name = "ValidationError"; } static fromZodError(error, type = "request") { const message = `${type} validation failed: ${error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ")}`; return new _ValidationError(message, error.issues, type); } }; var HttpError = class _HttpError extends ZodseiError { constructor(message, status, statusText, response) { super(message, "HTTP_ERROR"); this.status = status; this.statusText = statusText; this.response = response; this.name = "HttpError"; } static fromResponse(response, data) { const message = `HTTP ${response.status}: ${response.statusText}`; return new _HttpError(message, response.status, response.statusText, data); } }; var NetworkError = class extends ZodseiError { constructor(message, originalError) { super(message, "NETWORK_ERROR"); this.originalError = originalError; this.name = "NetworkError"; } }; var ConfigError = class extends ZodseiError { constructor(message) { super(message, "CONFIG_ERROR"); this.name = "ConfigError"; } }; var TimeoutError = class extends ZodseiError { constructor(timeout) { super(`Request timeout after ${timeout}ms`, "TIMEOUT_ERROR"); this.name = "TimeoutError"; } }; // src/validation.ts function validateRequest(schema, data) { if (!schema) { return data; } try { return schema.parse(data); } catch (error) { if (error instanceof z.ZodError) { throw ValidationError.fromZodError(error, "request"); } throw error; } } function validateResponse(schema, data) { if (!schema) { return data; } try { return schema.parse(data); } catch (error) { if (error instanceof z.ZodError) { throw ValidationError.fromZodError(error, "response"); } throw error; } } function safeParseRequest(schema, data) { if (!schema) { return { success: true, data }; } try { const result = schema.parse(data); return { success: true, data: result }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: ValidationError.fromZodError(error, "request") }; } return { success: false, error: new ValidationError("Unknown validation error", [], "request") }; } } function safeParseResponse(schema, data) { if (!schema) { return { success: true, data }; } try { const result = schema.parse(data); return { success: true, data: result }; } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: ValidationError.fromZodError(error, "response") }; } return { success: false, error: new ValidationError("Unknown validation error", [], "response") }; } } function createValidator(schema, enabled) { return { validateRequest: enabled ? (data) => validateRequest(schema, data) : (data) => data, validateResponse: enabled ? (data) => validateResponse(schema, data) : (data) => data, safeParseRequest: (data) => safeParseRequest(schema, data), safeParseResponse: (data) => safeParseResponse(schema, data) }; } // src/utils/path.ts function extractPathParamNames(path) { const matches = path.match(/:([^/]+)/g); return matches ? matches.map((match) => match.slice(1)) : []; } function replacePath(path, params) { let result = path; for (const [key, value] of Object.entries(params)) { result = result.replace(`:${key}`, encodeURIComponent(value)); } return result; } function buildQueryString(params) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== void 0 && value !== null) { if (Array.isArray(value)) { value.forEach((item) => searchParams.append(key, String(item))); } else { searchParams.append(key, String(value)); } } } const queryString = searchParams.toString(); return queryString ? `?${queryString}` : ""; } function buildUrl(path, query) { const cleanPath = path.startsWith("/") ? path : `/${path}`; const queryString = query ? buildQueryString(query) : ""; return `${cleanPath}${queryString}`; } function separateParams(path, data) { const pathParamNames = extractPathParamNames(path); const pathParams = {}; const queryParams = {}; if (!data) { return { pathParams, queryParams }; } for (const [key, value] of Object.entries(data)) { if (pathParamNames.includes(key)) { pathParams[key] = String(value); } else { queryParams[key] = value; } } return { pathParams, queryParams }; } function shouldHaveBody(method) { return !["GET", "HEAD", "DELETE"].includes(method.toUpperCase()); } // src/middleware/index.ts var MiddlewareExecutor = class { constructor(middleware = []) { this.middleware = middleware; } // Execute middleware chain async execute(request, finalHandler) { if (this.middleware.length === 0) { return finalHandler(request); } let index = 0; const next = async (req) => { if (index >= this.middleware.length) { return finalHandler(req); } const middleware = this.middleware[index++]; return middleware(req, next); }; return next(request); } // Add middleware use(middleware) { this.middleware.push(middleware); } // Get middleware list getMiddleware() { return [...this.middleware]; } }; function createMiddlewareExecutor(middleware = []) { return new MiddlewareExecutor(middleware); } function composeMiddleware(...middleware) { return async (request, next) => { const executor = new MiddlewareExecutor(middleware); return executor.execute(request, next); }; } // src/adapters/axios.ts import { isAxiosError } from "axios"; var AxiosAdapter = class { constructor(axiosInstance) { this.name = "axios"; this.axios = axiosInstance; } // Interceptors are not supported. Use middleware in the client instead. async request(context) { try { const axiosConfig = this.createAxiosConfig(context); const response = await this.axios.request(axiosConfig); const headers = (() => { const rh = response.headers ?? {}; if (!rh) return {}; try { return Object.fromEntries( Object.entries(rh).map(([k, v]) => [k, typeof v === "string" ? v : String(v)]) ); } catch { return {}; } })(); const responseContext = { status: response.status, statusText: response.statusText, headers, data: response.data }; if (response.status >= 400) { throw new HttpError( `HTTP ${response.status}: ${response.statusText}`, response.status, response.statusText, response.data ); } return responseContext; } catch (error) { if (error instanceof HttpError) { throw error; } if (isAxiosError(error)) { if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") { const to = typeof error.config?.timeout === "number" ? error.config.timeout : 0; throw new TimeoutError(to || 0); } if (error.response) { throw new HttpError( `HTTP ${error.response.status}: ${error.response.statusText}`, error.response.status, error.response.statusText, error.response.data ); } else if (error.request) { const message3 = typeof error.message === "string" ? error.message : "Request failed"; throw new NetworkError(`Network request failed: ${message3}`, error); } const message2 = typeof error.message === "string" ? error.message : "Axios error"; throw new NetworkError(`Axios request failed: ${message2}`, error); } const message = error instanceof Error ? error.message : "Unknown error"; throw new NetworkError( `Axios request failed: ${message}`, error ?? new Error(String(error)) ); } } createAxiosConfig(context) { const config = { url: context.url, method: context.method.toLowerCase(), headers: { "Content-Type": "application/json", ...context.headers } }; if (context.body !== void 0 && !["GET", "HEAD"].includes(context.method.toUpperCase())) { config.data = context.body; } if (context.query && Object.keys(context.query).length > 0) { config.params = context.query; } return config; } }; // src/schema.ts import { z as z2 } from "zod"; var SchemaExtractor = class _SchemaExtractor { constructor(contract) { this.contract = contract; } /** * Get endpoint definition by path */ getEndpoint(path) { const endpoint = this.contract[path]; if (this.isEndpointDefinition(endpoint)) { return endpoint; } throw new Error(`Endpoint "${String(path)}" not found or is not a valid endpoint`); } /** * Get nested contract by path */ getNested(path) { const nested = this.contract[path]; if (this.isNestedContract(nested)) { return new _SchemaExtractor(nested); } throw new Error(`Nested contract "${String(path)}" not found or is not a valid contract`); } /** * Get request schema for an endpoint */ getRequestSchema(path) { const endpoint = this.getEndpoint(path); return endpoint.request; } /** * Get response schema for an endpoint */ getResponseSchema(path) { const endpoint = this.getEndpoint(path); return endpoint.response; } /** * Get all schemas for an endpoint */ getEndpointSchemas(path) { const endpoint = this.getEndpoint(path); const result = { request: endpoint.request, response: endpoint.response, endpoint }; return result; } /** * Get all endpoint paths in the contract */ getEndpointPaths() { return Object.keys(this.contract).filter( (key) => this.isEndpointDefinition(this.contract[key]) ); } /** * Get all nested contract paths */ getNestedPaths() { return Object.keys(this.contract).filter( (key) => this.isNestedContract(this.contract[key]) ); } /** * Generate OpenAPI-like schema description */ describeEndpoint(path) { const endpoint = this.getEndpoint(path); const result = { path: endpoint.path, method: endpoint.method, requestSchema: endpoint.request, responseSchema: endpoint.response, requestType: endpoint.request ? this.getSchemaDescription(endpoint.request) : "void", responseType: endpoint.response ? this.getSchemaDescription(endpoint.response) : "unknown" }; return result; } /** * Generate schema description for documentation */ getSchemaDescription(schema) { if (!schema) { return "undefined"; } try { if (schema instanceof z2.ZodObject) { const shape = schema.shape; const fields = Object.keys(shape).map((key) => { const field = shape[key]; return `${key}: ${this.getZodTypeDescription(field)}`; }); return `{ ${fields.join(", ")} }`; } return this.getZodTypeDescription(schema); } catch { return "unknown"; } } /** * Get basic Zod type description */ getZodTypeDescription(schema) { const meta = schema; const def = meta.def ?? meta._def; if (def?.typeName) { switch (def.typeName) { case "ZodString": return "string"; case "ZodNumber": return "number"; case "ZodBoolean": return "boolean"; case "ZodArray": return def.type ? `${this.getZodTypeDescription(def.type)}[]` : "array"; case "ZodOptional": return def.innerType ? `${this.getZodTypeDescription(def.innerType)}?` : "optional"; case "ZodNullable": return def.innerType ? `${this.getZodTypeDescription(def.innerType)} | null` : "nullable"; case "ZodObject": return "object"; case "ZodUnion": return "union"; case "ZodLiteral": return `literal(${JSON.stringify(def.value)})`; case "ZodEnum": return "enum"; default: return def.typeName.replace("Zod", "").toLowerCase(); } } try { if (schema instanceof z2.ZodString) return "string"; if (schema instanceof z2.ZodNumber) return "number"; if (schema instanceof z2.ZodBoolean) return "boolean"; if (schema instanceof z2.ZodArray) { const element = schema.element; return element ? `${this.getZodTypeDescription(element)}[]` : "array"; } if (schema instanceof z2.ZodOptional) { const inner = schema.unwrap(); return inner ? `${this.getZodTypeDescription(inner)}?` : "optional"; } if (schema instanceof z2.ZodNullable) { const inner = schema.unwrap(); return inner ? `${this.getZodTypeDescription(inner)} | null` : "nullable"; } if (schema instanceof z2.ZodObject) return "object"; } catch { } return "unknown"; } /** * Check if a value is an endpoint definition */ isEndpointDefinition(value) { return Boolean(value) && typeof value === "object" && value !== null && "path" in value && "method" in value; } /** * Check if a value is a nested contract */ isNestedContract(value) { return Boolean(value) && typeof value === "object" && value !== null && !this.isEndpointDefinition(value); } }; function createSchemaExtractor(contract) { return new SchemaExtractor(contract); } function extractTypeInfo(endpoint) { return { requestSchema: endpoint.request, responseSchema: endpoint.response, method: endpoint.method, path: endpoint.path, hasRequestSchema: Boolean(endpoint.request), hasResponseSchema: Boolean(endpoint.response) }; } // src/client.ts var ZodseiClient = class { constructor(contract, config) { this.adapter = null; this.contract = contract; this.config = this.normalizeConfig(config); this.middlewareExecutor = createMiddlewareExecutor(this.config.middleware); this.$schema = createSchemaExtractor(contract); return new Proxy(this, { get: (target, prop) => { if (typeof prop === "string") { if (prop in this.contract && this.isEndpointDefinition(this.contract[prop])) { return this.createEndpointMethod(prop); } if (prop in this.contract && this.isNestedContract(this.contract[prop])) { return this.createNestedClient(this.contract[prop]); } } return Reflect.get(target, prop); } }); } /** * Normalize configuration */ normalizeConfig(config) { return { validateRequest: config.validateRequest ?? true, validateResponse: config.validateResponse ?? true, middleware: config.middleware ?? [], axios: config.axios }; } /** * Check if a value is an endpoint definition */ isEndpointDefinition(value) { return typeof value === "object" && value !== null && "path" in value && "method" in value; } /** * Check if a value is a nested contract */ isNestedContract(value) { return typeof value === "object" && value !== null && !this.isEndpointDefinition(value); } /** * Create nested client for sub-contracts */ createNestedClient(nestedContract) { return new Proxy( {}, { get: (_target, prop) => { if (typeof prop === "string") { if (prop in nestedContract && this.isEndpointDefinition(nestedContract[prop])) { return this.createEndpointMethod( `${prop}`, nestedContract[prop] ); } if (prop in nestedContract && this.isNestedContract(nestedContract[prop])) { return this.createNestedClient(nestedContract[prop]); } } return void 0; } } ); } /** * Create endpoint method with schema access */ createEndpointMethod(endpointName, endpoint) { const targetEndpoint = endpoint || this.contract[endpointName]; const method = async (...args) => { const data = targetEndpoint.request ? args[0] : void 0; return this.executeEndpoint(targetEndpoint, data); }; method.schema = { request: targetEndpoint.request, response: targetEndpoint.response, endpoint: targetEndpoint }; method.infer = { request: targetEndpoint.request ? {} : void 0, response: targetEndpoint.response ? {} : {} }; return method; } /** * Execute endpoint request */ async executeEndpoint(endpoint, data) { const validatedData = this.config.validateRequest ? validateRequest(endpoint.request, data) : data; const requestContext = this.buildRequestContext(endpoint, validatedData); const response = await this.middlewareExecutor.execute( requestContext, (ctx) => this.executeHttpRequest(ctx) ); const validatedResponse = this.config.validateResponse ? validateResponse(endpoint.response, response.data) : response.data; return validatedResponse; } /** * Build request context */ buildRequestContext(endpoint, data) { const { path, method } = endpoint; const { pathParams, queryParams } = separateParams( path, typeof data === "object" && data !== null ? data : void 0 ); const finalPath = replacePath(path, pathParams); const url = method.toLowerCase() === "get" ? buildUrl(finalPath, queryParams) : buildUrl(finalPath); const body = shouldHaveBody(method) ? method.toLowerCase() === "get" ? void 0 : data : void 0; return { url, method, headers: {}, body, params: pathParams, query: method.toLowerCase() === "get" ? queryParams : void 0 }; } /** * Get adapter */ async getAdapter() { if (!this.adapter) { this.adapter = new AxiosAdapter(this.config.axios); } return this.adapter; } /** * Execute HTTP request */ async executeHttpRequest(context) { const adapter = await this.getAdapter(); return adapter.request(context); } /** * Get configuration */ getConfig() { return { ...this.config }; } /** * Get contract */ getContract() { return { ...this.contract }; } /** * Add middleware */ use(middleware) { this.middlewareExecutor.use(middleware); } }; function createClient(contract, config) { return new ZodseiClient(contract, config); } // src/types.ts function defineContract(contract) { return contract; } // src/middleware/retry.ts function defaultRetryCondition(error) { if (error instanceof HttpError) { return error.status >= 500 || error.status === 408 || error.status === 429; } return true; } function calculateDelay(attempt, baseDelay, backoff) { switch (backoff) { case "exponential": return baseDelay * Math.pow(2, attempt); case "linear": default: return baseDelay * (attempt + 1); } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function retryMiddleware(config) { const { retries, delay: baseDelay, backoff = "exponential", retryCondition = defaultRetryCondition, onRetry } = config; return async (request, next) => { let lastError; for (let attempt = 0; attempt <= retries; attempt++) { try { return await next(request); } catch (error) { lastError = error; if (attempt === retries) { throw lastError; } if (!retryCondition(lastError)) { throw lastError; } if (onRetry) { onRetry(attempt + 1, lastError); } const delayTime = calculateDelay(attempt, baseDelay, backoff); await delay(delayTime); } } throw lastError; }; } function simpleRetry(retries, delay2 = 1e3) { return retryMiddleware({ retries, delay: delay2, backoff: "exponential" }); } // src/middleware/cache.ts var MemoryCacheStorage = class { constructor() { this.cache = /* @__PURE__ */ new Map(); } async get(key) { const entry = this.cache.get(key); if (!entry) { return null; } if (Date.now() - entry.timestamp > entry.ttl) { this.cache.delete(key); return null; } return entry; } async set(key, entry) { this.cache.set(key, entry); } async delete(key) { this.cache.delete(key); } async clear() { this.cache.clear(); } // Get cache size size() { return this.cache.size; } // Clean expired cache cleanup() { const now = Date.now(); for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > entry.ttl) { this.cache.delete(key); } } } }; function defaultKeyGenerator(request) { const { url, method, body, query } = request; const parts = [method.toUpperCase(), url]; if (query && Object.keys(query).length > 0) { parts.push(JSON.stringify(query)); } if (body) { parts.push(JSON.stringify(body)); } return parts.join("|"); } function defaultShouldCache(request, response) { return request.method.toLowerCase() === "get" && response.status >= 200 && response.status < 300; } function cacheMiddleware(config) { const { ttl, keyGenerator = defaultKeyGenerator, shouldCache = defaultShouldCache, storage = new MemoryCacheStorage() } = config; return async (request, next) => { const cacheKey = keyGenerator(request); const cachedEntry = await storage.get(cacheKey); if (cachedEntry) { return cachedEntry.data; } const response = await next(request); if (shouldCache(request, response)) { const entry = { data: response, timestamp: Date.now(), ttl }; await storage.set(cacheKey, entry); } return response; }; } function simpleCache(ttl) { return cacheMiddleware({ ttl }); } // src/utils/request.ts function mergeHeaders(defaultHeaders, requestHeaders) { return { ...defaultHeaders, ...requestHeaders }; } // src/index.ts import { z as z3 } from "zod"; export { AxiosAdapter, ConfigError, HttpError, MemoryCacheStorage, NetworkError, SchemaExtractor, TimeoutError, ValidationError, ZodseiClient, ZodseiError, buildQueryString, buildUrl, cacheMiddleware, composeMiddleware, createClient, createMiddlewareExecutor, createSchemaExtractor, createValidator, defineContract, extractPathParamNames, extractTypeInfo, mergeHeaders, replacePath, retryMiddleware, safeParseRequest, safeParseResponse, separateParams, shouldHaveBody, simpleCache, simpleRetry, validateRequest, validateResponse, z3 as z }; //# sourceMappingURL=index.mjs.map