diff --git a/README.md b/README.md index dab9877..d227ef2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ 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 @@ -52,7 +62,7 @@ tf - 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()` +- 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 @@ -198,6 +208,36 @@ const code = await tf.exportTypes({ outFile: 'typedfetch.generated.d.ts', banner 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 @@ -308,4 +348,4 @@ MIT License - see [LICENSE](LICENSE) for details. --- -**TypedFetch**: Because life's too short for complex HTTP clients. 🚀 \ No newline at end of file +**TypedFetch**: Because life's too short for complex HTTP clients. 🚀 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/resource-builder.ts b/src/core/resource-builder.ts index 22e1237..0b7aa64 100644 --- a/src/core/resource-builder.ts +++ b/src/core/resource-builder.ts @@ -85,23 +85,38 @@ export function createResource( const normalizedBase = normalizePath(basePath, options.trailingSlash) const resource: Record = {} - for (const [name, config] of Object.entries(definition)) { - resource[name] = async ( - args: ResourceCallArgs, Record> = {} - ) => { + for (const name of Object.keys(definition) as Array) { + type Config = TDefinition[typeof name] + type BodyType = Config extends ResourceMethodConfig ? TBody : never + type ParamsType = Config extends ResourceMethodConfig + ? TParams + : Record + type QueryType = Config extends ResourceMethodConfig + ? TQuery + : Record + type ResponseType = Config extends ResourceMethodConfig + ? TResponse + : unknown + const config = definition[name] as Config + + resource[name as string] = async (args: ResourceCallArgs = {}) => { 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 query = buildQuery({ + ...(options.query ?? {}), + ...(config.query ?? {}), + ...(args.query ?? {}) + }) const finalUrl = `${resolvedPath}${query}` const shouldUseJson = config.json ?? true - const serializer = config.serializeBody + const serializer = config.serializeBody as ((body: BodyType) => BodyInit | undefined) | undefined const hasBody = args.body !== undefined const prepared = hasBody ? serializer - ? { bodyInit: serializer(args.body), sample: args.body } - : prepareBodyPayload(args.body, { json: shouldUseJson }) + ? { bodyInit: serializer(args.body as BodyType), sample: args.body } + : prepareBodyPayload(args.body as BodyType, { json: shouldUseJson }) : undefined const init: RequestInit = { @@ -116,7 +131,7 @@ export function createResource( init.body = prepared?.bodyInit ?? null } - const result = await requester.request( + const result = await requester.request( method, finalUrl, init, @@ -137,7 +152,7 @@ export function createResource( query?: Record ) => { const resolved = maybeApplyTrailing(applyParams(normalizedBase, params), options.trailingSlash) - return `${resolved}${buildQuery({ ...options.query, ...query })}` + return `${resolved}${buildQuery({ ...(options.query ?? {}), ...(query ?? {}) })}` } return resource as ResourceInstance diff --git a/src/core/typed-fetch.ts b/src/core/typed-fetch.ts index f850b43..75a3366 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -17,6 +17,7 @@ import { createResource, type ResourceBuilderOptions, type ResourceDefinition, t 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' @@ -27,7 +28,7 @@ export { DEFAULT_CONFIG, mergeConfig } from '../types/config.js' const MAX_TYPE_SAMPLES = 5 -export class RevolutionaryTypedFetch { +export class RevolutionaryTypedFetch { private config: Required private cache: WTinyLFUCache private deduplicator = new RequestDeduplicator() @@ -88,9 +89,9 @@ export class RevolutionaryTypedFetch { /** * 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) + return new RevolutionaryTypedFetch(mergedConfig) } // REAL runtime type tracking @@ -176,48 +177,54 @@ 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, + async post>( + url: Url, body?: TBody, options: RequestInit = {} - ): Promise<{ data: TResponse; response: Response }> { + ): Promise<{ data: EndpointResponse; response: Response }> { if (body === undefined) { - return this.request('POST', url, options) + return this.request<'POST', Url>('POST', url, options) } const prepared = prepareBodyPayload(body) - return this.request('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample) + return this.request<'POST', Url>('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample) } - async put( - url: string, + async put>( + url: Url, body?: TBody, options: RequestInit = {} - ): Promise<{ data: TResponse; response: Response }> { + ): Promise<{ data: EndpointResponse; response: Response }> { if (body === undefined) { - return this.request('PUT', url, options) + return this.request<'PUT', Url>('PUT', url, options) } const prepared = prepareBodyPayload(body) - return this.request('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample) + return this.request<'PUT', Url>('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample) } - async patch( - url: string, + async patch>( + url: Url, body?: TBody, options: RequestInit = {} - ): Promise<{ data: TResponse; response: Response }> { + ): Promise<{ data: EndpointResponse; response: Response }> { if (body === undefined) { - return this.request('PATCH', url, options) + return this.request<'PATCH', Url>('PATCH', url, options) } const prepared = prepareBodyPayload(body) - return this.request('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample) + return this.request<'PATCH', Url>('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample) } - async delete(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> { - return this.request('DELETE', url, options) + async delete( + url: Url, + options: RequestInit = {} + ): Promise<{ data: EndpointResponse; response: Response }> { + return this.request<'DELETE', Url>('DELETE', url, options) } resource( @@ -227,7 +234,7 @@ export class RevolutionaryTypedFetch { ): ResourceInstance { return createResource(this, path, definition, options) } - + private resolveUrl(url: string): string { // Use baseURL from config or instance const baseURL = this.config.request.baseURL || this.baseURL @@ -242,12 +249,19 @@ export class RevolutionaryTypedFetch { } } - async request( - method: string, - url: string, + async request< + Method extends string, + Url extends string, + TResponse = EndpointResponse + >( + method: Method, + url: Url, options: RequestInit = {}, bodySample?: unknown - ): Promise<{ data: T; response: Response }> { + ): Promise<{ + data: TResponse + response: Response + }> { const fullUrl = this.resolveUrl(url) const cacheKey = `${method}:${fullUrl}` const startTime = performance.now() @@ -264,7 +278,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') } } } @@ -302,7 +316,7 @@ export class RevolutionaryTypedFetch { // 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) }) }) @@ -340,7 +354,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() @@ -374,8 +397,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 @@ -613,7 +636,7 @@ export class RevolutionaryTypedFetch { init.body = prepared.bodyInit sample = prepared.sample } - const result = await this.request(method, req.url, init, sample) + const result = await this.request(method, req.url, init, sample) return { data: result.data, response: result.response } } catch (error) { if (throwOnError) throw error @@ -763,7 +786,7 @@ export class RevolutionaryTypedFetch { init.body = prepared.bodyInit sample = prepared.sample } - const result = await this.request(method, req.url, init, sample) + const result = await this.request(method, req.url, init, sample) return { ...result, winner: index } }) @@ -812,7 +835,7 @@ export class RevolutionaryTypedFetch { init.body = prepared.bodyInit sample = prepared.sample } - const result = await this.request(method, req.url, init, sample) + const result = await this.request(method, req.url, init, sample) return { data: result.data, response: result.response } } catch (error) { return { error } @@ -1156,4 +1179,4 @@ export class RevolutionaryTypedFetch { return value * (multipliers[unit] || 1) } -} \ No newline at end of file +} 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 66f6d4e..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' 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/tests/body-utils.test.ts b/tests/body-utils.test.ts new file mode 100644 index 0000000..5eadb7a --- /dev/null +++ b/tests/body-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { mergePartialConfig, prepareBodyPayload } 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) + }) +}) 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' }") + }) +})