From bf1ee5f90740b5d2210cd278f70b83b1a303151d Mon Sep 17 00:00:00 2001 From: Casey Collier Date: Thu, 13 Nov 2025 01:22:26 -0500 Subject: [PATCH] Add built-in mocking utilities --- CHANGELOG.md | 6 + README.md | 194 +++++++++++++-- package.json | 7 +- src/cli.ts | 163 ++++++++++++ src/core/body-utils.ts | 152 +++++++++++ src/core/mock-controller.ts | 415 +++++++++++++++++++++++++++++++ src/core/presets.ts | 73 ++++++ src/core/resource-builder.ts | 194 +++++++++++++++ src/core/typed-fetch.ts | 403 ++++++++++++++++++++++++------ src/discovery/type-generator.ts | 94 +++++++ src/discovery/typed-api-proxy.ts | 4 +- src/index.ts | 29 ++- src/types/endpoint-types.ts | 36 +++ src/types/index.ts | 18 +- tests/body-utils.test.ts | 54 ++++ tests/mock-controller.test.ts | 107 ++++++++ tests/resource-builder.test.ts | 28 +++ tests/throttled.test.ts | 88 +++++++ tests/type-generator.test.ts | 38 +++ 19 files changed, 1995 insertions(+), 108 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/core/body-utils.ts create mode 100644 src/core/mock-controller.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/mock-controller.test.ts create mode 100644 tests/resource-builder.test.ts create mode 100644 tests/throttled.test.ts create mode 100644 tests/type-generator.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7fbfb..3c9e506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to TypedFetch will be documented in this file. +# [Unreleased] + +### Added +- Built-in mocking helpers (`tf.mock`, `tf.mockOnce`, `tf.clearMocks`, `tf.enableMocking`, `tf.disableMocking`) to short-circuit requests for tests, storybooks, and offline workflows while still feeding the type registry. + ## [0.2.0] - 2025-01-20 ### Added @@ -28,6 +33,7 @@ All notable changes to TypedFetch will be documented in this file. ### Fixed - Fixed streaming methods to properly resolve relative URLs with baseURL - Fixed TypeScript strict mode compatibility issues +- Ensured `throttled()` enforces bandwidth limits for streamed responses instead of being a no-op ## [0.1.3] - 2025-01-19 diff --git a/README.md b/README.md index a48d375..4e1aa4e 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,8 @@ 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 +- Inline request mocking utilities for tests, demos, and offline work ### ⚡ Performance - <15KB gzipped bundle @@ -74,51 +96,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 +158,127 @@ 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. + +### Built-in mocking for tests & demos + +TypedFetch now includes a zero-dependency mock adapter so you can short-circuit requests without swapping clients: + +```typescript +import { tf } from '@catalystlabs/typedfetch' + +// Match colon params or even RegExp/function matchers +tf.mock({ + method: 'GET', + url: '/users/:id', + handler: ({ params, query }) => ({ + data: { id: params.id, name: 'Mocked User', filter: query.filter } + }) +}) + +// Only intercept the next matching call +tf.mockOnce({ + method: 'POST', + url: '/users', + response: { status: 201, data: { id: 'temp-id' } } +}) + +// Toggle or reset mocks globally +tf.disableMocking() +tf.enableMocking() +tf.clearMocks() +``` + +Handlers receive params, query, headers, and even parsed bodies, and their `data` payloads still feed the type registry—perfect for storybooks, tests, or rapid prototyping without standing up a server. + +### 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/mock-controller.ts b/src/core/mock-controller.ts new file mode 100644 index 0000000..ed2e6f8 --- /dev/null +++ b/src/core/mock-controller.ts @@ -0,0 +1,415 @@ +export interface MockMatcherResult { + params?: Record + query?: Record + searchParams?: URLSearchParams +} + +export type MockMatcher = (url: URL) => boolean | MockMatcherResult | null | undefined + +export interface MockResponse { + data?: TData + status?: number + headers?: HeadersInit + body?: BodyInit | null + delay?: number + response?: Response +} + +export type MockHandlerResult = MockResponse | Response | TData | void + +export interface MockHandlerContext { + url: string + method: string + params: Record + query: Record + searchParams: URLSearchParams + headers: Record + body?: unknown + request: RequestInit +} + +export type MockHandler = (ctx: MockHandlerContext) => Promise> | MockHandlerResult + +export type MockRouteMatcher = string | RegExp | MockMatcher + +export interface MockRouteDefinition { + method?: string | string[] + url?: MockRouteMatcher + once?: boolean + priority?: number + handler?: MockHandler + response?: MockResponse +} + +interface MockMatchInternal { + route: InternalMockRoute + params: Record + query: Record + searchParams: URLSearchParams +} + +interface InternalMockRoute extends Required> { + id: number + methods: string[] | null + priority: number + matcher: (url: URL) => MatcherShape | null + handler: MockHandler | undefined + response: MockResponse | undefined +} + +interface MatcherShape { + params: Record + query: Record + searchParams: URLSearchParams +} + +interface ExecuteContext { + url: string + method: string + request: RequestInit + bodySample?: unknown + delay: (ms: number) => Promise +} + +interface NormalizedMockResult { + data: T + response: Response +} + +export class MockController { + private routes: InternalMockRoute[] = [] + private enabled = true + private counter = 0 + + register(definition: MockRouteDefinition): () => void { + const route: InternalMockRoute = { + id: ++this.counter, + once: Boolean(definition.once), + priority: definition.priority ?? 0, + methods: this.normalizeMethods(definition.method), + matcher: this.createMatcher(definition.url), + handler: definition.handler as MockHandler | undefined, + response: definition.response + } + + this.routes.push(route) + this.routes.sort((a, b) => (b.priority - a.priority) || (a.id - b.id)) + + return () => { + this.routes = this.routes.filter(entry => entry.id !== route.id) + } + } + + clear(): void { + this.routes = [] + } + + enable(): void { + this.enabled = true + } + + disable(): void { + this.enabled = false + } + + hasHandlers(): boolean { + return this.routes.length > 0 + } + + match(method: string, url: string): MockMatchInternal | null { + if (!this.enabled || !this.routes.length) { + return null + } + + const normalizedMethod = method.toUpperCase() + const parsed = new URL(url) + + for (const route of this.routes) { + if (route.methods && !route.methods.includes(normalizedMethod) && !route.methods.includes('ANY')) { + continue + } + + const match = route.matcher(parsed) + if (match) { + return { + route, + ...match + } + } + } + + return null + } + + async execute(match: MockMatchInternal, context: ExecuteContext): Promise> { + if (match.route.once) { + this.routes = this.routes.filter(route => route.id !== match.route.id) + } + + const handler = match.route.handler + const fallback = match.route.response + const headersRecord = this.toHeaderRecord(context.request.headers) + const body = context.bodySample !== undefined ? context.bodySample : this.tryDecodeBody(context.request.body) + const handlerContext: MockHandlerContext = { + url: context.url, + method: context.method, + params: match.params, + query: match.query, + searchParams: match.searchParams, + headers: headersRecord, + body, + request: context.request + } + + const output = handler ? await handler(handlerContext) : undefined + const resolved = output ?? fallback + + if (!resolved) { + return this.normalizeResult({ data: undefined }) as NormalizedMockResult + } + + if (typeof resolved === 'object' && resolved !== null && 'delay' in resolved && typeof resolved.delay === 'number') { + await context.delay(resolved.delay) + } + + return this.normalizeResult(resolved) + } + + private normalizeMethods(method?: string | string[]): string[] | null { + if (!method) return null + const list = Array.isArray(method) ? method : [method] + if (!list.length) return null + return list.map(m => m.toUpperCase()) + } + + private createMatcher(source?: MockRouteMatcher): (url: URL) => MatcherShape | null { + if (!source) { + return (url) => this.buildMatch(url) + } + + if (typeof source === 'string') { + return this.compileStringMatcher(source) + } + + if (source instanceof RegExp) { + return (url) => (source.test(url.href) ? this.buildMatch(url) : null) + } + + return (url) => { + const result = source(url) + if (result === true) return this.buildMatch(url) + if (!result) return null + const params = result.params ?? {} + const query = result.query ?? this.buildQuery(url) + const searchParams = result.searchParams ?? new URLSearchParams(url.search) + return { + params, + query, + searchParams + } + } + } + + private compileStringMatcher(pattern: string): (url: URL) => MatcherShape | null { + const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(pattern) + const parsedPattern = isAbsolute ? new URL(pattern) : null + const [rawPath, rawQuery] = pattern.split('?') + const targetPath = parsedPattern ? parsedPattern.pathname : (rawPath || '/') + const patternSegments = this.splitSegments(targetPath) + const wildcardIndex = patternSegments.indexOf('*') + const hasWildcard = wildcardIndex !== -1 && wildcardIndex === patternSegments.length - 1 + const queryConstraints = parsedPattern + ? parsedPattern.searchParams + : rawQuery + ? new URLSearchParams(rawQuery) + : new URLSearchParams() + + return (url) => { + if (parsedPattern && parsedPattern.origin !== url.origin) { + return null + } + + const urlSegments = this.splitSegments(url.pathname) + + if (!hasWildcard && patternSegments.length !== urlSegments.length) { + return null + } + + if (hasWildcard && urlSegments.length < wildcardIndex) { + return null + } + + const params: Record = {} + for (let i = 0; i < patternSegments.length; i++) { + const segment = patternSegments[i] + if (!segment) { + return null + } + if (segment === '*') { + break + } + const value = urlSegments[i] + if (segment.startsWith(':')) { + if (value === undefined) return null + params[segment.slice(1)] = decodeURIComponent(value) + continue + } + if (segment !== value) { + return null + } + } + + for (const [key, value] of queryConstraints.entries()) { + if (url.searchParams.get(key) !== value) { + return null + } + } + + return this.buildMatch(url, params) + } + } + + private buildMatch(url: URL, params: Record = {}): MatcherShape { + return { + params, + query: this.buildQuery(url), + searchParams: new URLSearchParams(url.search) + } + } + + private buildQuery(url: URL): Record { + const query: Record = {} + for (const [key, value] of url.searchParams.entries()) { + query[key] = value + } + return query + } + + private splitSegments(path: string): string[] { + return path.split('/').filter(Boolean) + } + + private normalizeResult(output: MockHandlerResult): NormalizedMockResult { + if (output instanceof Response) { + return { data: undefined as T, response: output } + } + + const candidate = typeof output === 'object' && output !== null && ( + 'data' in output || 'status' in output || 'headers' in output || 'body' in output || 'response' in output + ) + ? output as MockResponse + : { data: output } as MockResponse + + if (candidate.response instanceof Response) { + return { data: candidate.data as T, response: candidate.response } + } + + const bodySource = candidate.body ?? candidate.data + const serialized = this.serializeBody(bodySource) + const headers = new Headers(candidate.headers || {}) + if (serialized.kind === 'json' && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const response = new Response(serialized.body ?? '', { + status: candidate.status ?? 200, + headers + }) + + return { + data: candidate.data as T, + response + } + } + + private serializeBody(source: unknown): { body: BodyInit | null; kind: 'json' | 'raw' } { + if (source === undefined || source === null) { + return { body: null, kind: 'raw' } + } + + if (typeof source === 'string') { + return { body: source, kind: 'raw' } + } + + if (typeof Blob !== 'undefined' && source instanceof Blob) { + return { body: source, kind: 'raw' } + } + + if (typeof FormData !== 'undefined' && source instanceof FormData) { + return { body: source, kind: 'raw' } + } + + if (source instanceof URLSearchParams) { + return { body: source, kind: 'raw' } + } + + if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { + return { body: source, kind: 'raw' } + } + + if (typeof ArrayBuffer !== 'undefined' && source instanceof ArrayBuffer) { + return { body: source, kind: 'raw' } + } + + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(source)) { + return { body: source as unknown as BodyInit, kind: 'raw' } + } + + return { body: JSON.stringify(source), kind: 'json' } + } + + private tryDecodeBody(body: RequestInit['body']): unknown { + if (!body) return undefined + + if (typeof body === 'string') { + try { + return JSON.parse(body) + } catch { + return body + } + } + + if (body instanceof URLSearchParams) { + return Object.fromEntries(body.entries()) + } + + if (typeof FormData !== 'undefined' && body instanceof FormData) { + const result: Record = {} + for (const [key, raw] of body.entries()) { + const value = raw as FormDataEntryValue + if (key in result) { + const existing = result[key] + if (Array.isArray(existing)) { + existing.push(value) + } else { + result[key] = [existing as FormDataEntryValue, value] + } + } else { + result[key] = value + } + } + return result + } + + if ( + (typeof Blob !== 'undefined' && body instanceof Blob) || + (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) || + (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body)) || + (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + ) { + return body + } + + return body + } + + private toHeaderRecord(headers?: HeadersInit): Record { + const record: Record = {} + if (!headers) return record + const normalized = new Headers(headers) + normalized.forEach((value, key) => { + record[key] = value + }) + return record + } +} 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..0e60521 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -12,15 +12,24 @@ 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 { MockController, type MockRouteDefinition } from './mock-controller.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() @@ -32,6 +41,7 @@ export class RevolutionaryTypedFetch { private metrics = new RequestMetrics() private offlineHandler = new OfflineHandler() private baseURL = '' + private mockController = new MockController() constructor(config: TypedFetchConfig = {}) { this.config = mergeConfig(DEFAULT_CONFIG, config) @@ -64,28 +74,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 +179,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 +254,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 +283,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 +311,39 @@ export class RevolutionaryTypedFetch { // Process through interceptors const processedOptions = await this.interceptors.processRequest(requestOptions) + + if (bodySample !== undefined) { + this.recordRequest(url, method, bodySample) + } + + const mockMatch = this.mockController.match(method, fullUrl) + if (mockMatch) { + const mocked = await this.mockController.execute(mockMatch, { + url: fullUrl, + method, + request: processedOptions, + bodySample, + delay: (ms) => this.delay(ms) + }) + if (mocked.data !== undefined) { + this.recordResponse(url, method, mocked.data) + } + const duration = performance.now() - startTime + if (this.config.metrics.enabled) { + this.metrics.recordRequest(fullUrl, duration, cached) + } + if (this.config.debug.logSuccess) { + console.log(`✅ Mocked request: ${method} ${fullUrl} (${duration.toFixed(0)}ms)`) + } + return mocked + } // 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 +381,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 +424,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 +497,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 +656,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 @@ -597,6 +741,26 @@ export class RevolutionaryTypedFetch { variables }) } + + mock(definition: MockRouteDefinition): () => void { + return this.mockController.register(definition) + } + + mockOnce(definition: MockRouteDefinition): () => void { + return this.mockController.register({ ...definition, once: true }) + } + + clearMocks(): void { + this.mockController.clear() + } + + enableMocking(): void { + this.mockController.enable() + } + + disableMocking(): void { + this.mockController.disable() + } // Circuit breaker control resetCircuitBreaker(): void { @@ -662,14 +826,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 +875,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 } @@ -1004,46 +1168,143 @@ export class RevolutionaryTypedFetch { options: { bandwidth?: number | string // e.g., 1048576 (1MB/s) or '1MB/s' burst?: number // Allow burst up to this many bytes + sleep?: (ms: number) => Promise + now?: () => number } = {} ): Promise { - const { bandwidth = '1MB/s', burst = 0 } = options - + const { + bandwidth = '1MB/s', + burst = 0, + sleep: sleepOverride, + now: nowOverride + } = options + + const sleep = sleepOverride ?? ((ms: number) => this.delay(ms)) + const now = nowOverride ?? (() => Date.now()) + // Parse bandwidth string - const bytesPerSecond = typeof bandwidth === 'string' + const bytesPerSecond = typeof bandwidth === 'string' ? this.parseBandwidth(bandwidth) : bandwidth - + // Token bucket algorithm + const capacity = burst || bytesPerSecond const bucket = { - tokens: burst || bytesPerSecond, - lastRefill: Date.now(), - capacity: burst || bytesPerSecond + tokens: capacity, + lastRefill: now(), + capacity } - - // Refill tokens + const refill = () => { - const now = Date.now() - const elapsed = (now - bucket.lastRefill) / 1000 + const current = now() + const elapsed = (current - bucket.lastRefill) / 1000 const tokensToAdd = elapsed * bytesPerSecond bucket.tokens = Math.min(bucket.capacity, bucket.tokens + tokensToAdd) - bucket.lastRefill = now + bucket.lastRefill = current } - - // Wait for tokens + const waitForTokens = async (needed: number) => { refill() while (bucket.tokens < needed) { const deficit = needed - bucket.tokens const waitTime = (deficit / bytesPerSecond) * 1000 - await this.delay(Math.min(waitTime, 100)) // Check every 100ms + await sleep(Math.min(waitTime, 100)) refill() } bucket.tokens -= needed } - - // TODO: Implement actual throttling for the response stream - // For now, just execute the function - return fn() + + type StreamLike = ReadableStream | { getReader: () => { + read: () => Promise<{ value?: Uint8Array; done: boolean }> + cancel?: (reason?: unknown) => Promise | void + } } + + const isStreamLike = (candidate: any): candidate is StreamLike => { + return Boolean(candidate && typeof candidate.getReader === 'function') + } + + const throttleStream = (stream: StreamLike): ReadableStream => { + const reader = stream.getReader() + return new ReadableStream({ + pull: async (controller) => { + const { value, done } = await reader.read() + if (done) { + controller.close() + return + } + const chunkSize = value?.length ?? 0 + if (chunkSize > 0) { + await waitForTokens(chunkSize) + } + controller.enqueue(value) + }, + cancel: (reason) => { + if (typeof reader.cancel === 'function') { + const result = reader.cancel(reason) + if (result && typeof (result as Promise).then === 'function') { + ;(result as Promise).catch(() => {}) + } + } + } + }) + } + + const cloneResponse = (response: any) => { + if (!response?.body || !isStreamLike(response.body)) { + return response + } + + if (typeof Response !== 'undefined' && response instanceof Response) { + const throttledBody = throttleStream(response.body) + const cloned = new Response(throttledBody, { + headers: new Headers(response.headers), + status: response.status, + statusText: response.statusText + }) + + const copyProps: Array = ['url', 'type', 'redirected'] + for (const prop of copyProps) { + try { + Object.defineProperty(cloned, prop, { + configurable: true, + value: (response as any)[prop] + }) + } catch { + // Ignore if the environment doesn't allow redefining these properties + } + } + + return cloned + } + + return { ...response, body: throttleStream(response.body) } + } + + const maybeThrottle = (value: any): any => { + if (!value) return value + + if (typeof Response !== 'undefined' && value instanceof Response) { + return cloneResponse(value) + } + + if (isStreamLike(value)) { + return throttleStream(value) + } + + if (typeof value === 'object') { + if ('response' in value && value.response) { + return { ...value, response: cloneResponse(value.response) } + } + if ('body' in value && isStreamLike(value.body)) { + return { ...value, body: throttleStream(value.body) } + } + } + + return value + } + + const result = await fn() + return maybeThrottle(result) } private parseBandwidth(bandwidth: string): number { 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..0806b25 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,23 @@ 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 { MockController } from './core/mock-controller.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' +export type { + MockRouteDefinition, + MockHandler, + MockResponse, + MockMatcher, + MockMatcherResult +} from './core/mock-controller.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/mock-controller.test.ts b/tests/mock-controller.test.ts new file mode 100644 index 0000000..26cbefe --- /dev/null +++ b/tests/mock-controller.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js' + +describe('mock controller integration', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('intercepts matching requests and skips fetch', async () => { + const fetchSpy = vi.fn(() => Promise.reject(new Error('network should not be called'))) + vi.stubGlobal('fetch', fetchSpy) + + const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } }) + client.mock({ + method: 'GET', + url: '/users/:id', + response: { + data: { id: '123', name: 'Mock User' }, + status: 200 + } + }) + + const result = await client.get('/users/123') + expect(result.data).toEqual({ id: '123', name: 'Mock User' }) + expect(result.response.status).toBe(200) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('passes params, query, headers, and body to handlers', async () => { + const fetchSpy = vi.fn(() => Promise.reject(new Error('network should not be called'))) + vi.stubGlobal('fetch', fetchSpy) + + const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } }) + client.mock({ + method: 'PATCH', + url: '/users/:id', + handler: ({ params, query, headers, body }) => { + expect(params.id).toBe('42') + expect(query.filter).toBe('active') + expect(headers['x-test']).toBe('1') + expect((body as any).title).toBe('Director') + return { + data: { + id: params.id, + filter: query.filter, + seenHeader: headers['x-test'] + } + } + } + }) + + const { data } = await client.patch( + '/users/42?filter=active', + { title: 'Director' }, + { headers: { 'X-Test': '1' } } + ) + + expect(data).toEqual({ id: '42', filter: 'active', seenHeader: '1' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('mockOnce only intercepts the first call', async () => { + const fetchSpy = vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + vi.stubGlobal('fetch', fetchSpy) + + const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } }) + client.mockOnce({ + method: 'GET', + url: '/ping', + response: { data: { mocked: true } } + }) + + const first = await client.get('/ping') + expect(first.data).toEqual({ mocked: true }) + + const second = await client.get('/ping') + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(second.data).toEqual({ ok: true }) + }) + + it('clearMocks removes registered routes', async () => { + const fetchSpy = vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + vi.stubGlobal('fetch', fetchSpy) + + const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } }) + client.mock({ + method: 'GET', + url: '/users', + response: { data: [{ id: 1 }] } + }) + + client.clearMocks() + + const result = await client.get('/users') + expect(result.data).toEqual({ ok: true }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) +}) 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/throttled.test.ts b/tests/throttled.test.ts new file mode 100644 index 0000000..49233d7 --- /dev/null +++ b/tests/throttled.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js' + +const createChunkedStream = (chunks: number[]) => { + let index = 0 + return { + getReader() { + return { + async read() { + if (index >= chunks.length) { + return { done: true as const, value: undefined } + } + const size = chunks[index++] ?? 0 + return { done: false as const, value: new Uint8Array(size || 0) } + }, + async cancel() { + /* noop */ + } + } + } + } +} + +describe('throttled helper', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('throttles response streams before delivering subsequent chunks', async () => { + const tf = new RevolutionaryTypedFetch() + let current = 0 + const sleep = vi.fn(async (ms: number) => { + current += ms + }) + + const stream = createChunkedStream([1024, 1024]) + const throttled = await tf.throttled(() => Promise.resolve(stream), { + bandwidth: '1KB/s', + sleep, + now: () => current + }) + expect(typeof throttled.getReader).toBe('function') + + const reader = throttled.getReader() + const first = await reader.read() + expect(first.value?.length).toBe(1024) + + const second = await reader.read() + expect(second.value?.length).toBe(1024) + expect(sleep).toHaveBeenCalled() + + const final = await reader.read() + expect(final.done).toBe(true) + }) + + it('wraps nested response objects without touching data payloads', async () => { + const tf = new RevolutionaryTypedFetch() + let current = 0 + const sleep = vi.fn(async (ms: number) => { + current += ms + }) + + const original = { + body: createChunkedStream([2048, 2048]), + headers: new Headers({ 'X-Test': '1' }), + status: 202, + statusText: 'Accepted' + } + const payload = { data: { ok: true }, response: original } + + const result = await tf.throttled(() => Promise.resolve(payload), { + bandwidth: '1KB/s', + sleep, + now: () => current + }) + + expect(result.data).toEqual({ ok: true }) + expect(result.response).not.toBe(original) + expect(result.response.status).toBe(202) + + const reader = result.response.body!.getReader() + await reader.read() + await reader.read() + const tail = await reader.read() + expect(tail.done).toBe(true) + }) +}) 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' }") + }) +})