zodsei
Version:
Contract-first type-safe HTTP client with Zod validation
840 lines (828 loc) • 23.4 kB
JavaScript
// 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