From e7719050241503c06b2e613ed58acb8d3e27712b Mon Sep 17 00:00:00 2001 From: Casey Collier Date: Wed, 12 Nov 2025 21:29:44 -0500 Subject: [PATCH] feat: add presets, resources, and type snapshots --- README.md | 67 +++++++++++ src/core/body-utils.ts | 93 ++++++++++++++++ src/core/presets.ts | 73 ++++++++++++ src/core/resource-builder.ts | 190 ++++++++++++++++++++++++++++++++ src/core/typed-fetch.ts | 176 ++++++++++++++++++++++------- src/discovery/type-generator.ts | 94 ++++++++++++++++ src/index.ts | 13 ++- src/types/index.ts | 18 +-- 8 files changed, 675 insertions(+), 49 deletions(-) create mode 100644 src/core/body-utils.ts create mode 100644 src/core/presets.ts create mode 100644 src/core/resource-builder.ts create mode 100644 src/discovery/type-generator.ts diff --git a/README.md b/README.md index a48d375..dab9877 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ const client = createTypedFetch({ }) const { data, response } = await client.get('/users/123') + +// Apply presets on the fly +import { presets } from '@catalystlabs/typedfetch' + +tf + .use(presets.browser(), presets.auth.bearer('token-123')) + .get('/profile') ``` ## ✨ Features @@ -45,6 +52,7 @@ const { data, response } = await client.get('/users/123') - TypeScript inference for response data - No manual type casting needed - Type-safe error handling +- Generate `.d.ts` snapshots from runtime data or OpenAPI discovery via `tf.exportTypes()` ### 🛡️ Built-in Resilience - Automatic retries with exponential backoff @@ -57,6 +65,7 @@ const { data, response } = await client.get('/users/123') - Standard HTTP methods: get(), post(), put(), delete() - Consistent response format - Zero boilerplate +- Declarative `resource()` builder for human-friendly endpoint modules ### ⚡ Performance - <15KB gzipped bundle @@ -89,6 +98,13 @@ const { data, response } = await tf.put('https://api.example.com/users/123', { } }) +// PATCH request +const { data } = await tf.patch('https://api.example.com/users/123', { + body: { + title: 'Director of Engineering' + } +}) + // DELETE request const { data, response } = await tf.delete('https://api.example.com/users/123') ``` @@ -133,6 +149,57 @@ tf.configure({ }) ``` +### Opinionated presets + +```typescript +import { tf, presets } from '@catalystlabs/typedfetch' + +tf.use( + presets.browser(), + presets.resilient(), + presets.auth.bearer('my-token') +) + +// All subsequent calls inherit the composed behavior +await tf.get('/me') +``` + +Presets are just functions that emit config so you can compose them freely or build your own: `const edge = () => ({ cache: { ttl: 1000 } })`. + +### Declarative resources + +```typescript +import { tf } from '@catalystlabs/typedfetch' + +const users = tf.resource('/users/:id', { + show: { + method: 'GET' + }, + update: { + method: 'PATCH', + json: true + } +}) + +const { data } = await users.show({ params: { id: '42' } }) +await users.update({ params: { id: '42' }, body: { name: 'Nova' } }) +``` + +Resources automatically expand `:params`, merge query objects, and keep returning the standard `{ data, response }` tuple. + +### Type snapshot export + +```typescript +import { tf } from '@catalystlabs/typedfetch' + +await tf.discover('https://api.example.com') +const code = await tf.exportTypes({ outFile: 'typedfetch.generated.d.ts', banner: 'Example API' }) + +console.log('Types written to disk!') +``` + +`tf.exportTypes()` serializes everything the registry knows (OpenAPI + runtime samples) into a `.d.ts` file, which you can then import for fully typed API clients. + ### Response Format All methods return a consistent response format: diff --git a/src/core/body-utils.ts b/src/core/body-utils.ts new file mode 100644 index 0000000..3569224 --- /dev/null +++ b/src/core/body-utils.ts @@ -0,0 +1,93 @@ +import type { TypedFetchConfig } from '../types/config.js' + +export interface BodyPreparationOptions { + /** + * Force JSON serialization (default: true). Set to false when passing binary/FormData bodies + */ + json?: boolean +} + +export interface PreparedBody { + bodyInit: BodyInit | null + sample?: unknown +} + +const hasBlob = typeof Blob !== 'undefined' +const hasFormData = typeof FormData !== 'undefined' +const hasURLSearchParams = typeof URLSearchParams !== 'undefined' +const hasReadableStream = typeof ReadableStream !== 'undefined' + +function isBinaryBody(value: unknown): value is BodyInit { + if (value === null || value === undefined) return false + if (typeof value === 'string') return false + + if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) return true + if (hasBlob && value instanceof Blob) return true + if (hasFormData && value instanceof FormData) return true + if (hasURLSearchParams && value instanceof URLSearchParams) return true + if (hasReadableStream && value instanceof ReadableStream) return true + + return false +} + +function tryParseJSON(value: string): unknown { + try { + return JSON.parse(value) + } catch { + return undefined + } +} + +export function prepareBodyPayload(body: unknown, options: BodyPreparationOptions = {}): PreparedBody { + const { json = true } = options + + if (body === undefined) { + return { bodyInit: null } + } + + if (!json) { + return { bodyInit: body as BodyInit } + } + + if (typeof body === 'string') { + return { bodyInit: body, sample: tryParseJSON(body) } + } + + if (body === null || typeof body === 'number' || typeof body === 'boolean') { + return { bodyInit: JSON.stringify(body), sample: body } + } + + if (isBinaryBody(body)) { + return { bodyInit: body } + } + + if (typeof body === 'object') { + return { bodyInit: JSON.stringify(body), sample: body } + } + + return { bodyInit: body as BodyInit } +} + +export function mergePartialConfig( + target: TypedFetchConfig = {}, + source: TypedFetchConfig = {} +): TypedFetchConfig { + const result: TypedFetchConfig = { ...target } + + for (const key in source) { + const value = source[key as keyof TypedFetchConfig] + if (value === undefined) continue + + if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key as keyof TypedFetchConfig] = { + ...(result[key as keyof TypedFetchConfig] as Record | undefined), + ...value + } as any + } else { + result[key as keyof TypedFetchConfig] = value as any + } + } + + return result +} diff --git a/src/core/presets.ts b/src/core/presets.ts new file mode 100644 index 0000000..a8f3e6b --- /dev/null +++ b/src/core/presets.ts @@ -0,0 +1,73 @@ +import type { TypedFetchConfig } from '../types/config.js' +import { mergeConfig } from '../types/config.js' +import { mergePartialConfig } from './body-utils.js' + +export type TypedFetchPreset = + | TypedFetchConfig + | ((current: Required) => TypedFetchConfig) + +export function applyPresetChain( + current: Required, + presets: TypedFetchPreset[] +): { overrides: TypedFetchConfig; preview: Required } { + let preview = current + let overrides: TypedFetchConfig = {} + + for (const preset of presets) { + const patch = typeof preset === 'function' ? preset(preview) : preset + overrides = mergePartialConfig(overrides, patch) + preview = mergeConfig(preview, patch) + } + + return { overrides, preview } +} + +export const presets = { + browser(): TypedFetchPreset { + return () => ({ + cache: { ttl: 60000, maxSize: 200, enabled: true }, + retry: { maxAttempts: 2, delays: [100, 300, 600] }, + request: { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + } + }) + }, + resilient(): TypedFetchPreset { + return () => ({ + retry: { maxAttempts: 5, delays: [100, 250, 500, 1000, 2000] }, + circuit: { threshold: 3, timeout: 45000, enabled: true }, + metrics: { enabled: true } + }) + }, + offlineFirst(): TypedFetchPreset { + return () => ({ + cache: { ttl: 5 * 60 * 1000, maxSize: 1000, enabled: true }, + retry: { maxAttempts: 4, delays: [100, 250, 500, 1000] }, + debug: { verbose: false } + }) + }, + jsonApi(): TypedFetchPreset { + return () => ({ + request: { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + } + }) + }, + auth: { + bearer(token: string): TypedFetchPreset { + return () => ({ + request: { + headers: { + Authorization: token.startsWith('Bearer') ? token : `Bearer ${token}` + } + } + }) + } + } +} diff --git a/src/core/resource-builder.ts b/src/core/resource-builder.ts new file mode 100644 index 0000000..22e1237 --- /dev/null +++ b/src/core/resource-builder.ts @@ -0,0 +1,190 @@ +import { prepareBodyPayload } from './body-utils.js' + +export type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'OPTIONS' + | 'HEAD' + +export interface ResourceMethodConfig< + TResponse = unknown, + TBody = undefined, + TParams extends Record = Record, + TQuery extends Record = Record +> { + method?: HttpMethod + path?: string + query?: Partial + headers?: Record + json?: boolean + serializeBody?: (body: TBody) => BodyInit | undefined + transformResponse?: (payload: any, response: Response) => TResponse + description?: string + summary?: string +} + +export type ResourceDefinition = Record + +export interface ResourceCallArgs< + TBody, + TParams extends Record, + TQuery extends Record +> { + body?: TBody + params?: TParams + query?: TQuery + init?: RequestInit +} + +export type ResourceMethodInvoker = ( + args?: ResourceCallArgs< + Config extends ResourceMethodConfig ? TBody : never, + Config extends ResourceMethodConfig + ? TParams + : Record, + Config extends ResourceMethodConfig + ? TQuery + : Record + > +) => Promise<{ + data: Config extends ResourceMethodConfig ? TResponse : unknown + response: Response +}> + +export type ResourceInstance = { + [K in keyof TDefinition]: ResourceMethodInvoker +} & { + /** Build the full URL for this resource */ + $path( + params?: Record, + query?: Record + ): string +} + +export interface ResourceBuilderOptions { + trailingSlash?: boolean + query?: Record +} + +interface ResourceRequester { + request(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{ + data: T + response: Response + }> +} + +export function createResource( + requester: ResourceRequester, + basePath: string, + definition: TDefinition, + options: ResourceBuilderOptions = {} +): ResourceInstance { + const normalizedBase = normalizePath(basePath, options.trailingSlash) + const resource: Record = {} + + for (const [name, config] of Object.entries(definition)) { + resource[name] = async ( + args: ResourceCallArgs, Record> = {} + ) => { + const method = (config.method || 'GET').toUpperCase() + const path = joinPaths(normalizedBase, config.path, options.trailingSlash) + const resolvedPath = applyParams(path, args.params) + const query = buildQuery({ ...options.query, ...config.query, ...args.query }) + const finalUrl = `${resolvedPath}${query}` + + const shouldUseJson = config.json ?? true + const serializer = config.serializeBody + const hasBody = args.body !== undefined + const prepared = hasBody + ? serializer + ? { bodyInit: serializer(args.body), sample: args.body } + : prepareBodyPayload(args.body, { json: shouldUseJson }) + : undefined + + const init: RequestInit = { + ...(args.init || {}), + headers: { + ...config.headers, + ...(args.init?.headers || {}) + } + } + + if (hasBody) { + init.body = prepared?.bodyInit ?? null + } + + const result = await requester.request( + method, + finalUrl, + init, + hasBody ? prepared?.sample : undefined + ) + if (config.transformResponse) { + return { + data: config.transformResponse(result.data, result.response), + response: result.response + } + } + return result + } + } + + resource.$path = ( + params?: Record, + query?: Record + ) => { + const resolved = maybeApplyTrailing(applyParams(normalizedBase, params), options.trailingSlash) + return `${resolved}${buildQuery({ ...options.query, ...query })}` + } + + return resource as ResourceInstance +} + +function normalizePath(path: string, trailingSlash?: boolean): string { + if (!path) return trailingSlash ? '/' : '/' + if (path.startsWith('http')) return maybeApplyTrailing(path, trailingSlash) + const normalized = path.startsWith('/') ? path : `/${path}` + return maybeApplyTrailing(normalized, trailingSlash) +} + +function joinPaths(base: string, extra?: string, trailingSlash?: boolean): string { + if (!extra) return maybeApplyTrailing(base, trailingSlash) + if (extra.startsWith('http')) return maybeApplyTrailing(extra, trailingSlash) + const combined = `${base.replace(/\/$/, '')}/${extra.replace(/^\//, '')}` + return maybeApplyTrailing(combined.replace(/\/+/g, '/'), trailingSlash) +} + +function maybeApplyTrailing(path: string, trailingSlash?: boolean): string { + if (!path) return trailingSlash ? '/' : '' + if (trailingSlash === undefined) return path + return trailingSlash ? path.replace(/\/?$/, '/') : path.replace(/\/$/, '') +} + +function applyParams(path: string, params?: Record): string { + if (!params) return path + return path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => { + const value = params[key] + if (value === undefined) { + throw new Error(`Missing value for URL parameter :${key}`) + } + return encodeURIComponent(String(value)) + }) +} + +function buildQuery(query?: Record): string { + if (!query) return '' + const search = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue + if (Array.isArray(value)) { + value.forEach(item => search.append(key, String(item))) + } else { + search.append(key, String(value)) + } + } + const result = search.toString() + return result ? `?${result}` : '' +} diff --git a/src/core/typed-fetch.ts b/src/core/typed-fetch.ts index 959d9fd..f850b43 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -12,14 +12,21 @@ import { InterceptorChain } from './interceptors.js' import { RequestMetrics } from './metrics.js' import { OfflineHandler } from './offline-handler.js' import { createHttpError, enhanceError, type ErrorContext } from './errors.js' +import { prepareBodyPayload } from './body-utils.js' +import { createResource, type ResourceBuilderOptions, type ResourceDefinition, type ResourceInstance } from './resource-builder.js' +import { applyPresetChain, type TypedFetchPreset } from './presets.js' +import { TypeDeclarationGenerator, type TypeSnapshotOptions } from '../discovery/type-generator.js' import type { TypeRegistry, TypedError } from '../types/index.js' import type { TypedFetchConfig } from '../types/config.js' import { DEFAULT_CONFIG, mergeConfig } from '../types/config.js' +import { inferTypeDescriptor } from '../types/type-descriptor.js' // Re-export configuration types for convenience export type { TypedFetchConfig } from '../types/config.js' export { DEFAULT_CONFIG, mergeConfig } from '../types/config.js' +const MAX_TYPE_SAMPLES = 5 + export class RevolutionaryTypedFetch { private config: Required private cache: WTinyLFUCache @@ -64,6 +71,19 @@ export class RevolutionaryTypedFetch { // Always update baseURL from config this.baseURL = this.config.request.baseURL || '' } + + /** + * Apply preset configuration stacks for simple ergonomics + */ + use(...presetsInput: Array): this { + const flat = presetsInput.flat().filter(Boolean) as TypedFetchPreset[] + if (!flat.length) return this + const { overrides } = applyPresetChain(this.config, flat) + if (Object.keys(overrides).length > 0) { + this.configure(overrides) + } + return this + } /** * Create a new instance with custom configuration @@ -74,18 +94,39 @@ export class RevolutionaryTypedFetch { } // REAL runtime type tracking + private recordRequest(endpoint: string, method: string, body: unknown): void { + if (body === undefined) return + const key = this.buildRegistryKey(method, endpoint) + const entry = this.ensureRegistryEntry(key, method) + entry.request = inferTypeDescriptor(body) + entry.lastSeen = Date.now() + } + private recordResponse(endpoint: string, method: string, data: any): void { - const key = `${method.toUpperCase()} ${endpoint}` + const key = this.buildRegistryKey(method, endpoint) this.typeInference.addSample(key, data) - - // Update registry with inferred type - this.typeRegistry[key] = { - request: this.typeRegistry[key]?.request, - response: this.typeInference.inferType(key), - method: method.toUpperCase(), - lastSeen: Date.now(), - samples: [data] + const entry = this.ensureRegistryEntry(key, method) + const inferred = this.typeInference.inferType(key) + if (inferred) { + entry.response = inferred } + entry.lastSeen = Date.now() + entry.samples = [...entry.samples, data].slice(-MAX_TYPE_SAMPLES) + } + + private buildRegistryKey(method: string, endpoint: string): string { + return `${method.toUpperCase()} ${endpoint}` + } + + private ensureRegistryEntry(key: string, method: string) { + if (!this.typeRegistry[key]) { + this.typeRegistry[key] = { + method: method.toUpperCase(), + lastSeen: Date.now(), + samples: [] + } + } + return this.typeRegistry[key]! } // REAL auto-discovery implementation @@ -138,18 +179,54 @@ export class RevolutionaryTypedFetch { async get(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { return this.request('GET', url, options) } - - async post(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> { - return this.request('POST', url, { ...options, body: JSON.stringify(body) }) + + async post( + url: string, + body?: TBody, + options: RequestInit = {} + ): Promise<{ data: TResponse; response: Response }> { + if (body === undefined) { + return this.request('POST', url, options) + } + const prepared = prepareBodyPayload(body) + return this.request('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample) } - - async put(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> { - return this.request('PUT', url, { ...options, body: JSON.stringify(body) }) + + async put( + url: string, + body?: TBody, + options: RequestInit = {} + ): Promise<{ data: TResponse; response: Response }> { + if (body === undefined) { + return this.request('PUT', url, options) + } + const prepared = prepareBodyPayload(body) + return this.request('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample) } - + + async patch( + url: string, + body?: TBody, + options: RequestInit = {} + ): Promise<{ data: TResponse; response: Response }> { + if (body === undefined) { + return this.request('PATCH', url, options) + } + const prepared = prepareBodyPayload(body) + return this.request('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample) + } + async delete(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { return this.request('DELETE', url, options) } + + resource( + path: string, + definition: TDefinition, + options?: ResourceBuilderOptions + ): ResourceInstance { + return createResource(this, path, definition, options) + } private resolveUrl(url: string): string { // Use baseURL from config or instance @@ -165,7 +242,12 @@ export class RevolutionaryTypedFetch { } } - private async request(method: string, url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { + async request( + method: string, + url: string, + options: RequestInit = {}, + bodySample?: unknown + ): Promise<{ data: T; response: Response }> { const fullUrl = this.resolveUrl(url) const cacheKey = `${method}:${fullUrl}` const startTime = performance.now() @@ -210,6 +292,10 @@ export class RevolutionaryTypedFetch { // Process through interceptors const processedOptions = await this.interceptors.processRequest(requestOptions) + + if (bodySample !== undefined) { + this.recordRequest(url, method, bodySample) + } // Handle offline requests const result = await this.offlineHandler.handleRequest(fullUrl, processedOptions, async () => { @@ -361,6 +447,14 @@ export class RevolutionaryTypedFetch { getAllTypes(): TypeRegistry { return { ...this.typeRegistry } } + + async exportTypes(options: TypeSnapshotOptions = {}): Promise { + const generator = new TypeDeclarationGenerator(this.typeRegistry) + if (options.outFile) { + return generator.writeToFile(options) + } + return generator.generate(options) + } getInferenceConfidence(endpoint: string): number { return this.typeInference.getConfidence(endpoint) @@ -512,14 +606,14 @@ export class RevolutionaryTypedFetch { const chunkPromises = chunk.map(async (req) => { try { const method = req.method || 'GET' - const result = await this.request( - method, - req.url, - { - body: req.body ? JSON.stringify(req.body) : null, - headers: req.headers || {} - } - ) + const init: RequestInit = { headers: req.headers || {} } + let sample: unknown + if (req.body !== undefined) { + const prepared = prepareBodyPayload(req.body) + init.body = prepared.bodyInit + sample = prepared.sample + } + const result = await this.request(method, req.url, init, sample) return { data: result.data, response: result.response } } catch (error) { if (throwOnError) throw error @@ -662,14 +756,14 @@ export class RevolutionaryTypedFetch { const promises = requests.map(async (req, index) => { const method = req.method || 'GET' - const result = await this.request( - method, - req.url, - { - body: req.body ? JSON.stringify(req.body) : null, - headers: req.headers || {} - } - ) + const init: RequestInit = { headers: req.headers || {} } + let sample: unknown + if (req.body !== undefined) { + const prepared = prepareBodyPayload(req.body) + init.body = prepared.bodyInit + sample = prepared.sample + } + const result = await this.request(method, req.url, init, sample) return { ...result, winner: index } }) @@ -711,14 +805,14 @@ export class RevolutionaryTypedFetch { requests.map(async (req) => { try { const method = req.method || 'GET' - const result = await this.request( - method, - req.url, - { - body: req.body ? JSON.stringify(req.body) : null, - headers: req.headers || {} - } - ) + const init: RequestInit = { headers: req.headers || {} } + let sample: unknown + if (req.body !== undefined) { + const prepared = prepareBodyPayload(req.body) + init.body = prepared.bodyInit + sample = prepared.sample + } + const result = await this.request(method, req.url, init, sample) return { data: result.data, response: result.response } } catch (error) { return { error } diff --git a/src/discovery/type-generator.ts b/src/discovery/type-generator.ts new file mode 100644 index 0000000..887d2f8 --- /dev/null +++ b/src/discovery/type-generator.ts @@ -0,0 +1,94 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { TypeRegistry } from '../types/index.js' +import { typeDescriptorToString } from '../types/type-descriptor.js' + +export interface TypeSnapshotOptions { + outFile?: string + namespace?: string + banner?: string +} + +export class TypeDeclarationGenerator { + constructor(private readonly registry: TypeRegistry) {} + + generate(options: TypeSnapshotOptions = {}): string { + const { namespace, banner } = options + const lines: string[] = [] + lines.push('// -----------------------------------------------------------------------------') + lines.push('// TypedFetch type snapshot - generated from runtime + schema discovery') + lines.push('// -----------------------------------------------------------------------------') + if (banner) { + lines.push(`// ${banner}`) + } + lines.push('') + + const declarations: string[] = [] + const endpoints: string[] = [] + + const entries = Object.entries(this.registry) + if (entries.length === 0) { + declarations.push('// No endpoints recorded yet. Make a request or run tf.discover().') + } + + for (const [key, entry] of entries) { + const [rawMethod = 'GET', ...pathParts] = key.split(' ') + const path = pathParts.join(' ') || '/' + const method = rawMethod || 'GET' + const typeBase = formatName(method, path) + const requestTypeName = `${typeBase}Request` + const responseTypeName = `${typeBase}Response` + const requestDescriptor = entry.request || { type: 'unknown' } + const responseDescriptor = entry.response || { type: 'unknown' } + + declarations.push(`export type ${requestTypeName} = ${typeDescriptorToString(requestDescriptor)}`) + declarations.push(`export type ${responseTypeName} = ${typeDescriptorToString(responseDescriptor)}`) + declarations.push('') + + endpoints.push( + ` '${method} ${path}': { request: ${requestTypeName}; response: ${responseTypeName}; method: '${method}'; path: '${path}' }` + ) + } + + if (endpoints.length) { + declarations.push('export interface TypedFetchGeneratedEndpoints {') + declarations.push(...endpoints) + declarations.push('}') + } + + const content = [...lines, ...declarations].join('\n') + if (!namespace) { + return content + } + + const namespaced = [`declare namespace ${namespace} {`, ...indent(declarations), '}'] + return [...lines, ...namespaced].join('\n') + } + + async writeToFile(options: TypeSnapshotOptions): Promise { + const code = this.generate(options) + if (options.outFile) { + await mkdir(dirname(options.outFile), { recursive: true }) + await writeFile(options.outFile, code, 'utf8') + } + return code + } +} + +function formatName(method: string, path: string): string { + const methodName = method.toLowerCase().replace(/(^|[-_\s])(\w)/g, (_, __, char: string) => char.toUpperCase()) + const parts = path + .split('/') + .filter(Boolean) + .map((segment) => segment.replace(/[:{}]/g, '')) + .map((segment) => segment.replace(/(^|[-_\s])(\w)/g, (_, __, char: string) => char.toUpperCase())) + + const suffix = parts.join('') || 'Root' + return `${methodName}${suffix}` +} + +function indent(lines: string[], spaces = 2): string[] { + const pad = ' '.repeat(spaces) + return lines.map(line => (line ? `${pad}${line}` : line)) +} diff --git a/src/index.ts b/src/index.ts index a98aed3..66f6d4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,4 +36,15 @@ export { CircuitBreaker } from './core/circuit-breaker.js' export { InterceptorChain } from './core/interceptors.js' export { RequestMetrics } from './core/metrics.js' export { OfflineHandler } from './core/offline-handler.js' -export { RequestDeduplicator } from './cache/deduplicator.js' \ No newline at end of file +export { RequestDeduplicator } from './cache/deduplicator.js' +export { createResource } from './core/resource-builder.js' +export { TypeDeclarationGenerator } from './discovery/type-generator.js' +export { presets } from './core/presets.js' +export type { TypedFetchPreset } from './core/presets.js' +export type { + ResourceBuilderOptions, + ResourceDefinition, + ResourceInstance, + ResourceMethodConfig +} from './core/resource-builder.js' +export type { TypeSnapshotOptions } from './discovery/type-generator.js' \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 611654a..429ff2d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,8 @@ * TypedFetch - Type System and Core Types */ +import type { TypeDescriptor } from './type-descriptor.js' + // Advanced TypeScript utilities for runtime type inference export type InferFromJSON = T extends string ? string @@ -22,14 +24,16 @@ export type DeepPartial = { } // Runtime type storage for discovered APIs +export interface TypeRegistryEntry { + request?: TypeDescriptor + response?: TypeDescriptor + method: string + lastSeen: number + samples: unknown[] +} + export interface TypeRegistry { - [endpoint: string]: { - request: any - response: any - method: string - lastSeen: number - samples: any[] - } + [endpoint: string]: TypeRegistryEntry } // Enhanced error types