Merge pull request #2 from ccollier86/codex/investigate-and-improve-typedfetch-api-g2n7dw
Add CLI sync workflow and type-safe helpers
This commit is contained in:
commit
bd78730c1c
11 changed files with 432 additions and 53 deletions
44
README.md
44
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<TypedFetchGeneratedEndpoints>({
|
||||
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<TypedFetchGeneratedEndpoints>({
|
||||
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. 🚀
|
||||
**TypedFetch**: Because life's too short for complex HTTP clients. 🚀
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
163
src/cli.ts
Normal file
163
src/cli.ts
Normal file
|
|
@ -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<string, keyof ParsedArgs> = {
|
||||
b: 'base',
|
||||
o: 'out',
|
||||
n: 'namespace',
|
||||
c: 'config'
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<TypedFetchConfig | undefined> {
|
||||
if (!path) return undefined
|
||||
const resolved = resolve(path)
|
||||
const raw = await readFile(resolved, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`typedfetch <command> [options]
|
||||
|
||||
Commands:
|
||||
sync [--base <url>] [--out <file>] 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
|
||||
})
|
||||
|
|
@ -85,23 +85,38 @@ export function createResource<TDefinition extends ResourceDefinition>(
|
|||
const normalizedBase = normalizePath(basePath, options.trailingSlash)
|
||||
const resource: Record<string, any> = {}
|
||||
|
||||
for (const [name, config] of Object.entries(definition)) {
|
||||
resource[name] = async (
|
||||
args: ResourceCallArgs<any, Record<string, string | number>, Record<string, unknown>> = {}
|
||||
) => {
|
||||
for (const name of Object.keys(definition) as Array<keyof TDefinition>) {
|
||||
type Config = TDefinition[typeof name]
|
||||
type BodyType = Config extends ResourceMethodConfig<any, infer TBody, any, any> ? TBody : never
|
||||
type ParamsType = Config extends ResourceMethodConfig<any, any, infer TParams, any>
|
||||
? TParams
|
||||
: Record<string, string | number>
|
||||
type QueryType = Config extends ResourceMethodConfig<any, any, any, infer TQuery>
|
||||
? TQuery
|
||||
: Record<string, unknown>
|
||||
type ResponseType = Config extends ResourceMethodConfig<infer TResponse, any, any, any>
|
||||
? TResponse
|
||||
: unknown
|
||||
const config = definition[name] as Config
|
||||
|
||||
resource[name as string] = async (args: ResourceCallArgs<BodyType, ParamsType, QueryType> = {}) => {
|
||||
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<TDefinition extends ResourceDefinition>(
|
|||
init.body = prepared?.bodyInit ?? null
|
||||
}
|
||||
|
||||
const result = await requester.request<any>(
|
||||
const result = await requester.request<ResponseType>(
|
||||
method,
|
||||
finalUrl,
|
||||
init,
|
||||
|
|
@ -137,7 +152,7 @@ export function createResource<TDefinition extends ResourceDefinition>(
|
|||
query?: Record<string, unknown>
|
||||
) => {
|
||||
const resolved = maybeApplyTrailing(applyParams(normalizedBase, params), options.trailingSlash)
|
||||
return `${resolved}${buildQuery({ ...options.query, ...query })}`
|
||||
return `${resolved}${buildQuery({ ...(options.query ?? {}), ...(query ?? {}) })}`
|
||||
}
|
||||
|
||||
return resource as ResourceInstance<TDefinition>
|
||||
|
|
|
|||
|
|
@ -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<TEndpoints extends EndpointTypeMap = EndpointTypeMap> {
|
||||
private config: Required<TypedFetchConfig>
|
||||
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<TEndpoints> {
|
||||
const mergedConfig = mergeConfig(this.config, config)
|
||||
return new RevolutionaryTypedFetch(mergedConfig)
|
||||
return new RevolutionaryTypedFetch<TEndpoints>(mergedConfig)
|
||||
}
|
||||
|
||||
// REAL runtime type tracking
|
||||
|
|
@ -176,48 +177,54 @@ export class RevolutionaryTypedFetch {
|
|||
}
|
||||
|
||||
// REAL HTTP methods with full type safety
|
||||
async get<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('GET', url, options)
|
||||
async get<Url extends string>(
|
||||
url: Url,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'GET', Url>; response: Response }> {
|
||||
return this.request<'GET', Url>('GET', url, options)
|
||||
}
|
||||
|
||||
async post<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
async post<Url extends string, TBody = EndpointRequest<TEndpoints, 'POST', Url>>(
|
||||
url: Url,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'POST', Url>; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('POST', url, options)
|
||||
return this.request<'POST', Url>('POST', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
return this.request<'POST', Url>('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
}
|
||||
|
||||
async put<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
async put<Url extends string, TBody = EndpointRequest<TEndpoints, 'PUT', Url>>(
|
||||
url: Url,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'PUT', Url>; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('PUT', url, options)
|
||||
return this.request<'PUT', Url>('PUT', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
return this.request<'PUT', Url>('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
}
|
||||
|
||||
async patch<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
async patch<Url extends string, TBody = EndpointRequest<TEndpoints, 'PATCH', Url>>(
|
||||
url: Url,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'PATCH', Url>; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('PATCH', url, options)
|
||||
return this.request<'PATCH', Url>('PATCH', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
return this.request<'PATCH', Url>('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
}
|
||||
|
||||
async delete<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('DELETE', url, options)
|
||||
async delete<Url extends string>(
|
||||
url: Url,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'DELETE', Url>; response: Response }> {
|
||||
return this.request<'DELETE', Url>('DELETE', url, options)
|
||||
}
|
||||
|
||||
resource<TDefinition extends ResourceDefinition>(
|
||||
|
|
@ -227,7 +234,7 @@ export class RevolutionaryTypedFetch {
|
|||
): ResourceInstance<TDefinition> {
|
||||
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<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
async request<
|
||||
Method extends string,
|
||||
Url extends string,
|
||||
TResponse = EndpointResponse<TEndpoints, Method, Url>
|
||||
>(
|
||||
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<T>(fullUrl, processedOptions, url, method)
|
||||
return this.executeWithRetry<Method, Url, TResponse>(fullUrl, processedOptions, url, method)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -340,7 +354,16 @@ export class RevolutionaryTypedFetch {
|
|||
}
|
||||
}
|
||||
|
||||
private async executeWithRetry<T>(fullUrl: string, options: any, originalUrl: string, method: string): Promise<{ data: T; response: Response }> {
|
||||
private async executeWithRetry<
|
||||
Method extends string,
|
||||
Url extends string,
|
||||
TResponse = EndpointResponse<TEndpoints, Method, Url>
|
||||
>(
|
||||
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<T>(method, req.url, init, sample)
|
||||
const result = await this.request<string, string, T>(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<T>(method, req.url, init, sample)
|
||||
const result = await this.request<string, string, T>(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<T>(method, req.url, init, sample)
|
||||
const result = await this.request<string, string, T>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
import type { RevolutionaryTypedFetch } from '../core/typed-fetch.js'
|
||||
|
||||
export class TypedAPIProxy {
|
||||
private client: RevolutionaryTypedFetch
|
||||
private client: RevolutionaryTypedFetch<any>
|
||||
private baseURL: string
|
||||
private path: string[]
|
||||
|
||||
constructor(client: RevolutionaryTypedFetch, baseURL: string, path: string[] = []) {
|
||||
constructor(client: RevolutionaryTypedFetch<any>, baseURL: string, path: string[] = []) {
|
||||
this.client = client
|
||||
this.baseURL = baseURL
|
||||
this.path = path
|
||||
|
|
|
|||
|
|
@ -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<TEndpoints extends EndpointTypeMap = EndpointTypeMap>(
|
||||
config?: TypedFetchConfig
|
||||
): RevolutionaryTypedFetch<TEndpoints> {
|
||||
return new RevolutionaryTypedFetch<TEndpoints>(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
src/types/endpoint-types.ts
Normal file
36
src/types/endpoint-types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export interface EndpointTypeEntry {
|
||||
request?: unknown
|
||||
response?: unknown
|
||||
}
|
||||
|
||||
export type EndpointTypeMap = Record<string, EndpointTypeEntry>
|
||||
|
||||
export type NormalizePath<Path extends string> = Path extends ''
|
||||
? '/'
|
||||
: Path extends `/${string}`
|
||||
? Path
|
||||
: Path extends `${string}://${string}`
|
||||
? Path
|
||||
: `/${Path}`
|
||||
|
||||
export type EndpointKey<Method extends string, Path extends string> = `${Uppercase<Method>} ${NormalizePath<Path>}`
|
||||
|
||||
export type EndpointResponse<
|
||||
Map extends EndpointTypeMap,
|
||||
Method extends string,
|
||||
Path extends string
|
||||
> = EndpointKey<Method, Path> extends keyof Map
|
||||
? Map[EndpointKey<Method, Path>] extends { response?: infer R }
|
||||
? R
|
||||
: unknown
|
||||
: unknown
|
||||
|
||||
export type EndpointRequest<
|
||||
Map extends EndpointTypeMap,
|
||||
Method extends string,
|
||||
Path extends string
|
||||
> = EndpointKey<Method, Path> extends keyof Map
|
||||
? Map[EndpointKey<Method, Path>] extends { request?: infer R }
|
||||
? R
|
||||
: unknown
|
||||
: unknown
|
||||
27
tests/body-utils.test.ts
Normal file
27
tests/body-utils.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
28
tests/resource-builder.test.ts
Normal file
28
tests/resource-builder.test.ts
Normal file
|
|
@ -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<T>(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')
|
||||
})
|
||||
})
|
||||
38
tests/type-generator.test.ts
Normal file
38
tests/type-generator.test.ts
Normal file
|
|
@ -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' }")
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue