From 43d4ea78b33214e07809520b51054095953076e5 Mon Sep 17 00:00:00 2001 From: Casey Collier Date: Wed, 12 Nov 2025 22:35:13 -0500 Subject: [PATCH] Support fetch-style init for body helpers --- README.md | 162 ++++++++++++++++++---- package.json | 7 +- src/cli.ts | 163 ++++++++++++++++++++++ src/core/body-utils.ts | 152 +++++++++++++++++++++ src/core/presets.ts | 73 ++++++++++ src/core/resource-builder.ts | 194 ++++++++++++++++++++++++++ src/core/typed-fetch.ts | 224 ++++++++++++++++++++++++------- src/discovery/type-generator.ts | 94 +++++++++++++ src/discovery/typed-api-proxy.ts | 4 +- src/index.ts | 21 ++- src/types/endpoint-types.ts | 36 +++++ src/types/index.ts | 18 ++- tests/body-utils.test.ts | 54 ++++++++ tests/resource-builder.test.ts | 28 ++++ tests/type-generator.test.ts | 38 ++++++ 15 files changed, 1179 insertions(+), 89 deletions(-) create mode 100644 src/cli.ts 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 create mode 100644 src/types/endpoint-types.ts create mode 100644 tests/body-utils.test.ts create mode 100644 tests/resource-builder.test.ts create mode 100644 tests/type-generator.test.ts diff --git a/README.md b/README.md index a48d375..c6f775f 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,32 @@ console.log(data.name) // Full response data import { createTypedFetch } from '@catalystlabs/typedfetch' const client = createTypedFetch({ - baseURL: 'https://api.example.com', - headers: { - 'Authorization': 'Bearer token' + request: { + baseURL: 'https://api.example.com', + headers: { + 'Authorization': 'Bearer token' + } } }) 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') + +// Bring your generated endpoint map for zero-effort typing +import type { TypedFetchGeneratedEndpoints } from './typedfetch.generated' + +const typed = createTypedFetch({ + request: { baseURL: 'https://api.example.com' } +}) + +const profile = await typed.get('/me') +// profile.data is strongly typed based on your schema/runtime samples ``` ## ✨ Features @@ -45,6 +64,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()` or the `typedfetch sync` CLI ### 🛡️ Built-in Resilience - Automatic retries with exponential backoff @@ -57,6 +77,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 @@ -74,51 +95,61 @@ import { tf } from '@catalystlabs/typedfetch' // GET request const { data, response } = await tf.get('https://api.example.com/users') -// POST request +// POST request (fetch-style RequestInit object) const { data, response } = await tf.post('https://api.example.com/users', { body: { name: 'John Doe', email: 'john@example.com' + }, + headers: { + 'X-Demo': 'docs' } }) -// PUT request -const { data, response } = await tf.put('https://api.example.com/users/123', { - body: { - name: 'Jane Doe' - } +// PUT request (pass the body directly and optional init as third arg) +const { data: updatedUser } = await tf.put('https://api.example.com/users/123', + { name: 'Jane Doe' }, + { headers: { 'X-Docs': '1' } } +) + +// PATCH request (body only) +const { data } = await tf.patch('https://api.example.com/users/123', { + title: 'Director of Engineering' }) // DELETE request const { data, response } = await tf.delete('https://api.example.com/users/123') ``` +`post`, `put`, and `patch` accept either a Fetch-style `RequestInit` object (with `body`, `headers`, etc.) or the raw body as the second argument plus an optional third `RequestInit` for headers/signals. + ### Configuration ```typescript import { createTypedFetch } from '@catalystlabs/typedfetch' const client = createTypedFetch({ - baseURL: 'https://api.example.com', - headers: { - 'Authorization': 'Bearer token', - 'Content-Type': 'application/json' + request: { + baseURL: 'https://api.example.com', + headers: { + 'Authorization': 'Bearer token', + 'Content-Type': 'application/json' + }, + timeout: 30000 }, - timeout: 30000, - + // Retry configuration retry: { - attempts: 3, - delay: 1000, - maxDelay: 10000, - backoff: 'exponential' + maxAttempts: 3, + delays: [100, 250, 500, 1000], + retryableStatuses: [408, 429, 500, 502, 503, 504] }, - + // Cache configuration cache: { enabled: true, ttl: 300000, // 5 minutes - storage: 'memory' // or 'indexeddb' + maxSize: 500 } }) @@ -126,13 +157,96 @@ const client = createTypedFetch({ import { tf } from '@catalystlabs/typedfetch' tf.configure({ - baseURL: 'https://api.example.com', - headers: { - 'Authorization': 'Bearer token' + request: { + baseURL: 'https://api.example.com', + headers: { + 'Authorization': 'Bearer token' + } } }) ``` +### 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!') +``` + +### CLI-powered type generation + +Prefer a single command? Install (or `npx`) the bundled CLI: + +```bash +npx typedfetch sync --base https://api.example.com \ + --out src/generated/typedfetch.generated.d.ts \ + --namespace API +``` + +The CLI will: + +1. Instantiate a `RevolutionaryTypedFetch` client using your optional `--config` JSON file +2. Run schema discovery (`tf.discover`) against the provided base URL +3. Emit a type snapshot to `--out` (or stdout if omitted) + +Use the emitted types to get end-to-end inference: + +```typescript +import type { TypedFetchGeneratedEndpoints } from './src/generated/typedfetch.generated' +import { createTypedFetch } from '@catalystlabs/typedfetch' + +const client = createTypedFetch({ + request: { baseURL: 'https://api.example.com' } +}) + +// Response + request body types are wired up automatically +const { data } = await client.get('/users/:id') +``` + +`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/package.json b/package.json index e8fd191..f61ae72 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "typedfetch": "./dist/cli.js" + }, "exports": { ".": { "import": "./dist/index.js", @@ -19,12 +22,14 @@ "LICENSE" ], "scripts": { - "build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:types", + "build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:cli && bun run build:types", "build:clean": "rm -rf dist && mkdir dist", "build:esm": "bun build src/index.ts --outdir dist --target browser --format esm", "build:cjs": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --target=node16 --format=cjs --platform=node", + "build:cli": "bun build src/cli.ts --outfile dist/cli.js --target node --format esm", "build:types": "tsc --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", + "test": "bun x vitest run", "sync-version": "node scripts/sync-version.js", "prepublishOnly": "bun run build && bun run typecheck" }, diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..884b753 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { RevolutionaryTypedFetch } from './core/typed-fetch.js' +import type { TypedFetchConfig } from './types/config.js' +import type { TypeSnapshotOptions } from './discovery/type-generator.js' + +interface ParsedArgs { + command: string + base?: string + out?: string + namespace?: string + banner?: string + config?: string + verbose?: boolean +} + +const SHORT_FLAGS: Record = { + b: 'base', + o: 'out', + n: 'namespace', + c: 'config' +} + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)) + const command = parsed.command || 'sync' + + switch (command) { + case 'sync': + await handleSync(parsed) + break + case 'help': + case '--help': + printHelp() + break + default: + console.error(`Unknown command: ${command}`) + printHelp() + process.exitCode = 1 + } +} + +function parseArgs(argv: string[]): ParsedArgs { + const args: ParsedArgs = { command: '' } + const positionals: string[] = [] + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]! + if (!token.startsWith('-')) { + positionals.push(token) + continue + } + + if (token.startsWith('--')) { + const segment = token.slice(2) + const [rawFlag, maybeValue] = segment.split('=', 2) + const flag = rawFlag ?? '' + if (!flag) continue + if (flag.startsWith('no-')) { + ;(args as any)[flag.slice(3)] = false + continue + } + + if (maybeValue !== undefined) { + ;(args as any)[flag] = maybeValue + continue + } + + const next = argv[i + 1] + if (!next || next.startsWith('-')) { + ;(args as any)[flag] = true + } else { + ;(args as any)[flag] = next + i++ + } + continue + } + + const short = token.replace(/^-+/, '') + const mapped = SHORT_FLAGS[short] + if (mapped) { + const next = argv[i + 1] + if (!next || next.startsWith('-')) { + throw new Error(`Flag -${short} requires a value`) + } + ;(args as any)[mapped] = next + i++ + } + } + + args.command = positionals[0] || args.command || 'sync' + if (!args.base && positionals[1]) { + args.base = positionals[1] + } + if (!args.out && positionals[2]) { + args.out = positionals[2] + } + + return args +} + +async function handleSync(args: ParsedArgs): Promise { + const config = await loadConfig(args.config) + const client = new RevolutionaryTypedFetch(config) + + const baseURL = args.base || config?.request?.baseURL + if (!baseURL) { + throw new Error('A base URL is required. Pass --base or set request.baseURL in your config file.') + } + + if (args.verbose) { + console.log(`🔍 Discovering schema from ${baseURL}`) + } + + await client.discover(baseURL) + + const snapshotOptions: TypeSnapshotOptions = {} + if (args.out) snapshotOptions.outFile = args.out + if (args.namespace) snapshotOptions.namespace = args.namespace + if (args.banner) snapshotOptions.banner = args.banner + + const code = await client.exportTypes(snapshotOptions) + if (args.out) { + if (args.verbose) { + console.log(`📄 Type snapshot written to ${resolve(args.out)}`) + } else { + console.log(`📄 Wrote ${args.out}`) + } + } else { + process.stdout.write(code) + } +} + +async function loadConfig(path?: string): Promise { + if (!path) return undefined + const resolved = resolve(path) + const raw = await readFile(resolved, 'utf8') + return JSON.parse(raw) +} + +function printHelp(): void { + console.log(`typedfetch [options] + +Commands: + sync [--base ] [--out ] Discover the API and emit TypeScript definitions + help Show this help message + +Options: + --base, -b Base URL to discover (falls back to config request.baseURL) + --out, -o File path for the generated declaration file (prints to stdout if omitted) + --namespace, -n Wrap declarations in a namespace + --banner Add a custom banner comment to the snapshot + --config, -c Path to a JSON config file matching TypedFetchConfig + --verbose Print progress information +`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/src/core/body-utils.ts b/src/core/body-utils.ts new file mode 100644 index 0000000..8342f2c --- /dev/null +++ b/src/core/body-utils.ts @@ -0,0 +1,152 @@ +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 +} + +export interface ResolvedBodyArgs { + body?: TBody + init: RequestInit +} + +const REQUEST_INIT_KEYS = new Set([ + 'body', + 'headers', + 'method', + 'mode', + 'credentials', + 'cache', + 'redirect', + 'referrer', + 'referrerPolicy', + 'integrity', + 'keepalive', + 'signal', + 'priority', + 'window', + 'duplex' +]) + +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 +} + +export function isRequestInitLike(value: unknown): value is RequestInit { + if (!value || typeof value !== 'object') return false + for (const key of REQUEST_INIT_KEYS) { + if (key in (value as Record)) { + return true + } + } + return false +} + +export function resolveBodyArgs( + bodyOrInit?: TBody | RequestInit, + maybeInit?: RequestInit +): ResolvedBodyArgs { + if (bodyOrInit === undefined) { + return { init: maybeInit ? { ...maybeInit } : {} } + } + + if (isRequestInitLike(bodyOrInit)) { + const { body, ...rest } = bodyOrInit as RequestInit & Record + const init = rest as RequestInit + if (body === undefined) { + return { init } + } + return { + body: body as TBody, + init + } + } + + return { + body: bodyOrInit as TBody, + init: maybeInit ? { ...maybeInit } : {} + } +} 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..6706f37 --- /dev/null +++ b/src/core/resource-builder.ts @@ -0,0 +1,194 @@ +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: any + 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 as { data: unknown; response: Response } + } + } + + 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..d423a0b 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -12,15 +12,23 @@ 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, resolveBodyArgs } 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 { EndpointRequest, EndpointResponse, EndpointTypeMap } from '../types/endpoint-types.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' -export class RevolutionaryTypedFetch { +const MAX_TYPE_SAMPLES = 5 + +export class RevolutionaryTypedFetch { private config: Required private cache: WTinyLFUCache private deduplicator = new RequestDeduplicator() @@ -64,28 +72,62 @@ 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 */ - create(config: TypedFetchConfig): RevolutionaryTypedFetch { + create(config: TypedFetchConfig): RevolutionaryTypedFetch { const mergedConfig = mergeConfig(this.config, config) return new RevolutionaryTypedFetch(mergedConfig) } // 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 @@ -135,20 +177,65 @@ export class RevolutionaryTypedFetch { } // REAL HTTP methods with full type safety - async get(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { - return this.request('GET', url, options) + async get( + url: Url, + options: RequestInit = {} + ): Promise<{ data: EndpointResponse; response: Response }> { + return this.request<'GET', Url>('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: Url, + bodyOrInit?: TBody | RequestInit, + maybeInit?: RequestInit + ): Promise<{ data: EndpointResponse; response: Response }> { + const { body, init } = resolveBodyArgs(bodyOrInit, maybeInit) + if (body === undefined) { + return this.request<'POST', Url>('POST', url, init) + } + const prepared = prepareBodyPayload(body) + return this.request<'POST', Url>('POST', url, { ...init, 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: Url, + bodyOrInit?: TBody | RequestInit, + maybeInit?: RequestInit + ): Promise<{ data: EndpointResponse; response: Response }> { + const { body, init } = resolveBodyArgs(bodyOrInit, maybeInit) + if (body === undefined) { + return this.request<'PUT', Url>('PUT', url, init) + } + const prepared = prepareBodyPayload(body) + return this.request<'PUT', Url>('PUT', url, { ...init, body: prepared.bodyInit }, prepared.sample) } - - async delete(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { - return this.request('DELETE', url, options) + + async patch>( + url: Url, + bodyOrInit?: TBody | RequestInit, + maybeInit?: RequestInit + ): Promise<{ data: EndpointResponse; response: Response }> { + const { body, init } = resolveBodyArgs(bodyOrInit, maybeInit) + if (body === undefined) { + return this.request<'PATCH', Url>('PATCH', url, init) + } + const prepared = prepareBodyPayload(body) + return this.request<'PATCH', Url>('PATCH', url, { ...init, body: prepared.bodyInit }, prepared.sample) + } + + async delete( + url: Url, + options: RequestInit = {} + ): Promise<{ data: EndpointResponse; response: Response }> { + return this.request<'DELETE', Url>('DELETE', url, options) + } + + resource( + path: string, + definition: TDefinition, + options?: ResourceBuilderOptions + ): ResourceInstance { + return createResource(this, path, definition, options) } private resolveUrl(url: string): string { @@ -165,7 +252,19 @@ export class RevolutionaryTypedFetch { } } - private async request(method: string, url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { + async request< + Method extends string, + Url extends string, + TResponse = EndpointResponse + >( + method: Method, + url: Url, + options: RequestInit = {}, + bodySample?: unknown + ): Promise<{ + data: TResponse + response: Response + }> { const fullUrl = this.resolveUrl(url) const cacheKey = `${method}:${fullUrl}` const startTime = performance.now() @@ -182,7 +281,7 @@ export class RevolutionaryTypedFetch { if (this.config.metrics.enabled) { this.metrics.recordRequest(fullUrl, duration, cached) } - return { data: cachedData as T, response: new Response('cached') } + return { data: cachedData as TResponse, response: new Response('cached') } } } @@ -210,13 +309,17 @@ 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 () => { // Deduplicate identical requests return this.deduplicator.dedupe(cacheKey, async () => { // Execute with circuit breaker and retry logic - return this.executeWithRetry(fullUrl, processedOptions, url, method) + return this.executeWithRetry(fullUrl, processedOptions, url, method) }) }) @@ -254,7 +357,16 @@ export class RevolutionaryTypedFetch { } } - private async executeWithRetry(fullUrl: string, options: any, originalUrl: string, method: string): Promise<{ data: T; response: Response }> { + private async executeWithRetry< + Method extends string, + Url extends string, + TResponse = EndpointResponse + >( + fullUrl: string, + options: any, + originalUrl: Url, + method: Method + ): Promise<{ data: TResponse; response: Response }> { let lastError: any const maxAttempts = method === 'GET' ? (this.config.retry.maxAttempts || 1) : 1 const startTime = performance.now() @@ -288,8 +400,8 @@ export class RevolutionaryTypedFetch { // Process through response interceptors const processedResponse = await this.interceptors.processResponse({ data, response }) - - return processedResponse + + return processedResponse as { data: TResponse; response: Response } } // Execute with or without circuit breaker @@ -361,6 +473,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 +632,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 +782,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 +831,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/discovery/typed-api-proxy.ts b/src/discovery/typed-api-proxy.ts index 215a173..a835784 100644 --- a/src/discovery/typed-api-proxy.ts +++ b/src/discovery/typed-api-proxy.ts @@ -5,11 +5,11 @@ import type { RevolutionaryTypedFetch } from '../core/typed-fetch.js' export class TypedAPIProxy { - private client: RevolutionaryTypedFetch + private client: RevolutionaryTypedFetch private baseURL: string private path: string[] - constructor(client: RevolutionaryTypedFetch, baseURL: string, path: string[] = []) { + constructor(client: RevolutionaryTypedFetch, baseURL: string, path: string[] = []) { this.client = client this.baseURL = baseURL this.path = path diff --git a/src/index.ts b/src/index.ts index a98aed3..6d83d04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,18 +15,22 @@ // Main client import { RevolutionaryTypedFetch } from './core/typed-fetch.js' import type { TypedFetchConfig } from './types/config.js' +import type { EndpointTypeMap } from './types/endpoint-types.js' // Export main instances export const tf = new RevolutionaryTypedFetch() -export function createTypedFetch(config?: TypedFetchConfig): RevolutionaryTypedFetch { - return new RevolutionaryTypedFetch(config) +export function createTypedFetch( + config?: TypedFetchConfig +): RevolutionaryTypedFetch { + return new RevolutionaryTypedFetch(config) } // Export types for advanced usage export type { TypeRegistry, InferFromJSON, TypedError } from './types/index.js' export type { TypedFetchConfig } from './types/config.js' export type { TypeDescriptor } from './types/type-descriptor.js' +export type { EndpointTypeEntry, EndpointTypeMap } from './types/endpoint-types.js' // Export core classes for advanced usage export { RuntimeTypeInference } from './types/runtime-inference.js' @@ -36,4 +40,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/endpoint-types.ts b/src/types/endpoint-types.ts new file mode 100644 index 0000000..2f76b35 --- /dev/null +++ b/src/types/endpoint-types.ts @@ -0,0 +1,36 @@ +export interface EndpointTypeEntry { + request?: unknown + response?: unknown +} + +export type EndpointTypeMap = Record + +export type NormalizePath = Path extends '' + ? '/' + : Path extends `/${string}` + ? Path + : Path extends `${string}://${string}` + ? Path + : `/${Path}` + +export type EndpointKey = `${Uppercase} ${NormalizePath}` + +export type EndpointResponse< + Map extends EndpointTypeMap, + Method extends string, + Path extends string +> = EndpointKey extends keyof Map + ? Map[EndpointKey] extends { response?: infer R } + ? R + : unknown + : unknown + +export type EndpointRequest< + Map extends EndpointTypeMap, + Method extends string, + Path extends string +> = EndpointKey extends keyof Map + ? Map[EndpointKey] extends { request?: infer R } + ? R + : unknown + : unknown 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 diff --git a/tests/body-utils.test.ts b/tests/body-utils.test.ts new file mode 100644 index 0000000..f38baef --- /dev/null +++ b/tests/body-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { mergePartialConfig, prepareBodyPayload, resolveBodyArgs } from '../src/core/body-utils.js' +import type { TypedFetchConfig } from '../src/types/config.js' + +describe('body utils', () => { + it('serializes plain objects and preserves samples', () => { + const payload = { id: 1, name: 'Ada' } + const prepared = prepareBodyPayload(payload) + expect(prepared.bodyInit).toBe(JSON.stringify(payload)) + expect(prepared.sample).toEqual(payload) + }) + + it('merges nested config objects deeply', () => { + const base: TypedFetchConfig = { + request: { headers: { Authorization: 'token' } } + } + const override: TypedFetchConfig = { + request: { headers: { 'X-Test': '1' } }, + cache: { ttl: 42 } + } + + const merged = mergePartialConfig(base, override) + expect(merged.request?.headers).toEqual({ 'X-Test': '1' }) + expect(merged.cache?.ttl).toBe(42) + }) + + it('normalizes fetch-style init objects and plucks body', () => { + const init: RequestInit = { + body: JSON.stringify({ id: 1 }), + headers: { 'X-Custom': '1' } + } + + const resolved = resolveBodyArgs(init) + expect(resolved.body).toEqual(JSON.stringify({ id: 1 })) + expect(resolved.init).toEqual({ headers: { 'X-Custom': '1' } }) + }) + + it('treats plain payloads as body values', () => { + const payload = { title: 'Hello' } + const resolved = resolveBodyArgs(payload) + expect(resolved.body).toBe(payload) + expect(resolved.init).toEqual({}) + }) + + it('copies the third argument when body is separate', () => { + const payload = { title: 'Hello' } + const init: RequestInit = { headers: { Accept: 'application/json' } } + const resolved = resolveBodyArgs(payload, init) + expect(resolved.body).toBe(payload) + expect(resolved.init).not.toBe(init) + expect(resolved.init).toEqual({ headers: { Accept: 'application/json' } }) + }) +}) diff --git a/tests/resource-builder.test.ts b/tests/resource-builder.test.ts new file mode 100644 index 0000000..0a41deb --- /dev/null +++ b/tests/resource-builder.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { createResource, type ResourceDefinition } from '../src/core/resource-builder.js' + +describe('resource builder', () => { + const response = new Response('{}') + + it('expands params and merges query defaults', async () => { + const calls: Array<{ method: string; url: string; bodySample?: unknown }> = [] + const requester = { + async request(method: string, url: string, init?: RequestInit, bodySample?: unknown) { + calls.push({ method, url, bodySample }) + return { data: { ok: true } as T, response } + } + } + + const definition: ResourceDefinition = { + show: { method: 'GET', path: '', query: { expand: 'profile' } } + } + + const resource = createResource(requester, '/users/:id', definition, { query: { locale: 'en' } }) + await resource.show({ params: { id: 42 }, query: { expand: 'posts' } }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.url).toBe('/users/42?locale=en&expand=posts') + expect(resource.$path({ id: 42 }, { search: 'text' })).toBe('/users/42?locale=en&search=text') + }) +}) diff --git a/tests/type-generator.test.ts b/tests/type-generator.test.ts new file mode 100644 index 0000000..dad8588 --- /dev/null +++ b/tests/type-generator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { TypeDeclarationGenerator } from '../src/discovery/type-generator.js' +import type { TypeRegistry } from '../src/types/index.js' + +const registry: TypeRegistry = { + 'GET /users': { + method: 'GET', + lastSeen: Date.now(), + samples: [], + request: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'] + }, + response: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' } + }, + required: ['id'] + } + } + } +} + +describe('type generator', () => { + it('emits request and response aliases', () => { + const generator = new TypeDeclarationGenerator(registry) + const snapshot = generator.generate({ namespace: 'API' }) + expect(snapshot).toContain('export type GetUsersRequest') + expect(snapshot).toContain('declare namespace API') + expect(snapshot).toContain("'GET /users': { request: GetUsersRequest; response: GetUsersResponse; method: 'GET'; path: '/users' }") + }) +})