Merge pull request #1 from ccollier86/codex/investigate-and-improve-typedfetch-api
Add presets, resource builder, and type snapshots
This commit is contained in:
commit
7fb1426049
8 changed files with 675 additions and 49 deletions
67
README.md
67
README.md
|
|
@ -37,6 +37,13 @@ const client = createTypedFetch({
|
|||
})
|
||||
|
||||
const { data, response } = await client.get('/users/123')
|
||||
|
||||
// Apply presets on the fly
|
||||
import { presets } from '@catalystlabs/typedfetch'
|
||||
|
||||
tf
|
||||
.use(presets.browser(), presets.auth.bearer('token-123'))
|
||||
.get('/profile')
|
||||
```
|
||||
|
||||
## ✨ Features
|
||||
|
|
@ -45,6 +52,7 @@ const { data, response } = await client.get('/users/123')
|
|||
- TypeScript inference for response data
|
||||
- No manual type casting needed
|
||||
- Type-safe error handling
|
||||
- Generate `.d.ts` snapshots from runtime data or OpenAPI discovery via `tf.exportTypes()`
|
||||
|
||||
### 🛡️ Built-in Resilience
|
||||
- Automatic retries with exponential backoff
|
||||
|
|
@ -57,6 +65,7 @@ const { data, response } = await client.get('/users/123')
|
|||
- Standard HTTP methods: get(), post(), put(), delete()
|
||||
- Consistent response format
|
||||
- Zero boilerplate
|
||||
- Declarative `resource()` builder for human-friendly endpoint modules
|
||||
|
||||
### ⚡ Performance
|
||||
- <15KB gzipped bundle
|
||||
|
|
@ -89,6 +98,13 @@ const { data, response } = await tf.put('https://api.example.com/users/123', {
|
|||
}
|
||||
})
|
||||
|
||||
// PATCH request
|
||||
const { data } = await tf.patch('https://api.example.com/users/123', {
|
||||
body: {
|
||||
title: 'Director of Engineering'
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE request
|
||||
const { data, response } = await tf.delete('https://api.example.com/users/123')
|
||||
```
|
||||
|
|
@ -133,6 +149,57 @@ tf.configure({
|
|||
})
|
||||
```
|
||||
|
||||
### Opinionated presets
|
||||
|
||||
```typescript
|
||||
import { tf, presets } from '@catalystlabs/typedfetch'
|
||||
|
||||
tf.use(
|
||||
presets.browser(),
|
||||
presets.resilient(),
|
||||
presets.auth.bearer('my-token')
|
||||
)
|
||||
|
||||
// All subsequent calls inherit the composed behavior
|
||||
await tf.get('/me')
|
||||
```
|
||||
|
||||
Presets are just functions that emit config so you can compose them freely or build your own: `const edge = () => ({ cache: { ttl: 1000 } })`.
|
||||
|
||||
### Declarative resources
|
||||
|
||||
```typescript
|
||||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
const users = tf.resource('/users/:id', {
|
||||
show: {
|
||||
method: 'GET'
|
||||
},
|
||||
update: {
|
||||
method: 'PATCH',
|
||||
json: true
|
||||
}
|
||||
})
|
||||
|
||||
const { data } = await users.show({ params: { id: '42' } })
|
||||
await users.update({ params: { id: '42' }, body: { name: 'Nova' } })
|
||||
```
|
||||
|
||||
Resources automatically expand `:params`, merge query objects, and keep returning the standard `{ data, response }` tuple.
|
||||
|
||||
### Type snapshot export
|
||||
|
||||
```typescript
|
||||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
await tf.discover('https://api.example.com')
|
||||
const code = await tf.exportTypes({ outFile: 'typedfetch.generated.d.ts', banner: 'Example API' })
|
||||
|
||||
console.log('Types written to disk!')
|
||||
```
|
||||
|
||||
`tf.exportTypes()` serializes everything the registry knows (OpenAPI + runtime samples) into a `.d.ts` file, which you can then import for fully typed API clients.
|
||||
|
||||
### Response Format
|
||||
|
||||
All methods return a consistent response format:
|
||||
|
|
|
|||
93
src/core/body-utils.ts
Normal file
93
src/core/body-utils.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { TypedFetchConfig } from '../types/config.js'
|
||||
|
||||
export interface BodyPreparationOptions {
|
||||
/**
|
||||
* Force JSON serialization (default: true). Set to false when passing binary/FormData bodies
|
||||
*/
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface PreparedBody {
|
||||
bodyInit: BodyInit | null
|
||||
sample?: unknown
|
||||
}
|
||||
|
||||
const hasBlob = typeof Blob !== 'undefined'
|
||||
const hasFormData = typeof FormData !== 'undefined'
|
||||
const hasURLSearchParams = typeof URLSearchParams !== 'undefined'
|
||||
const hasReadableStream = typeof ReadableStream !== 'undefined'
|
||||
|
||||
function isBinaryBody(value: unknown): value is BodyInit {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return false
|
||||
|
||||
if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true
|
||||
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) return true
|
||||
if (hasBlob && value instanceof Blob) return true
|
||||
if (hasFormData && value instanceof FormData) return true
|
||||
if (hasURLSearchParams && value instanceof URLSearchParams) return true
|
||||
if (hasReadableStream && value instanceof ReadableStream) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function tryParseJSON(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareBodyPayload(body: unknown, options: BodyPreparationOptions = {}): PreparedBody {
|
||||
const { json = true } = options
|
||||
|
||||
if (body === undefined) {
|
||||
return { bodyInit: null }
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
return { bodyInit: body as BodyInit }
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return { bodyInit: body, sample: tryParseJSON(body) }
|
||||
}
|
||||
|
||||
if (body === null || typeof body === 'number' || typeof body === 'boolean') {
|
||||
return { bodyInit: JSON.stringify(body), sample: body }
|
||||
}
|
||||
|
||||
if (isBinaryBody(body)) {
|
||||
return { bodyInit: body }
|
||||
}
|
||||
|
||||
if (typeof body === 'object') {
|
||||
return { bodyInit: JSON.stringify(body), sample: body }
|
||||
}
|
||||
|
||||
return { bodyInit: body as BodyInit }
|
||||
}
|
||||
|
||||
export function mergePartialConfig(
|
||||
target: TypedFetchConfig = {},
|
||||
source: TypedFetchConfig = {}
|
||||
): TypedFetchConfig {
|
||||
const result: TypedFetchConfig = { ...target }
|
||||
|
||||
for (const key in source) {
|
||||
const value = source[key as keyof TypedFetchConfig]
|
||||
if (value === undefined) continue
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
result[key as keyof TypedFetchConfig] = {
|
||||
...(result[key as keyof TypedFetchConfig] as Record<string, unknown> | undefined),
|
||||
...value
|
||||
} as any
|
||||
} else {
|
||||
result[key as keyof TypedFetchConfig] = value as any
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
73
src/core/presets.ts
Normal file
73
src/core/presets.ts
Normal file
|
|
@ -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>) => TypedFetchConfig)
|
||||
|
||||
export function applyPresetChain(
|
||||
current: Required<TypedFetchConfig>,
|
||||
presets: TypedFetchPreset[]
|
||||
): { overrides: TypedFetchConfig; preview: Required<TypedFetchConfig> } {
|
||||
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}`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/core/resource-builder.ts
Normal file
190
src/core/resource-builder.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { prepareBodyPayload } from './body-utils.js'
|
||||
|
||||
export type HttpMethod =
|
||||
| 'GET'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'PATCH'
|
||||
| 'DELETE'
|
||||
| 'OPTIONS'
|
||||
| 'HEAD'
|
||||
|
||||
export interface ResourceMethodConfig<
|
||||
TResponse = unknown,
|
||||
TBody = undefined,
|
||||
TParams extends Record<string, string | number> = Record<string, string | number>,
|
||||
TQuery extends Record<string, unknown> = Record<string, unknown>
|
||||
> {
|
||||
method?: HttpMethod
|
||||
path?: string
|
||||
query?: Partial<TQuery>
|
||||
headers?: Record<string, string>
|
||||
json?: boolean
|
||||
serializeBody?: (body: TBody) => BodyInit | undefined
|
||||
transformResponse?: (payload: any, response: Response) => TResponse
|
||||
description?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export type ResourceDefinition = Record<string, ResourceMethodConfig>
|
||||
|
||||
export interface ResourceCallArgs<
|
||||
TBody,
|
||||
TParams extends Record<string, string | number>,
|
||||
TQuery extends Record<string, unknown>
|
||||
> {
|
||||
body?: TBody
|
||||
params?: TParams
|
||||
query?: TQuery
|
||||
init?: RequestInit
|
||||
}
|
||||
|
||||
export type ResourceMethodInvoker<Config extends ResourceMethodConfig> = (
|
||||
args?: ResourceCallArgs<
|
||||
Config extends ResourceMethodConfig<any, infer TBody, any, any> ? TBody : never,
|
||||
Config extends ResourceMethodConfig<any, any, infer TParams, any>
|
||||
? TParams
|
||||
: Record<string, string | number>,
|
||||
Config extends ResourceMethodConfig<any, any, any, infer TQuery>
|
||||
? TQuery
|
||||
: Record<string, unknown>
|
||||
>
|
||||
) => Promise<{
|
||||
data: Config extends ResourceMethodConfig<infer TResponse, any, any, any> ? TResponse : unknown
|
||||
response: Response
|
||||
}>
|
||||
|
||||
export type ResourceInstance<TDefinition extends ResourceDefinition> = {
|
||||
[K in keyof TDefinition]: ResourceMethodInvoker<TDefinition[K]>
|
||||
} & {
|
||||
/** Build the full URL for this resource */
|
||||
$path(
|
||||
params?: Record<string, string | number>,
|
||||
query?: Record<string, unknown>
|
||||
): string
|
||||
}
|
||||
|
||||
export interface ResourceBuilderOptions {
|
||||
trailingSlash?: boolean
|
||||
query?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ResourceRequester {
|
||||
request<T>(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{
|
||||
data: T
|
||||
response: Response
|
||||
}>
|
||||
}
|
||||
|
||||
export function createResource<TDefinition extends ResourceDefinition>(
|
||||
requester: ResourceRequester,
|
||||
basePath: string,
|
||||
definition: TDefinition,
|
||||
options: ResourceBuilderOptions = {}
|
||||
): ResourceInstance<TDefinition> {
|
||||
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>> = {}
|
||||
) => {
|
||||
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<any>(
|
||||
method,
|
||||
finalUrl,
|
||||
init,
|
||||
hasBody ? prepared?.sample : undefined
|
||||
)
|
||||
if (config.transformResponse) {
|
||||
return {
|
||||
data: config.transformResponse(result.data, result.response),
|
||||
response: result.response
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
resource.$path = (
|
||||
params?: Record<string, string | number>,
|
||||
query?: Record<string, unknown>
|
||||
) => {
|
||||
const resolved = maybeApplyTrailing(applyParams(normalizedBase, params), options.trailingSlash)
|
||||
return `${resolved}${buildQuery({ ...options.query, ...query })}`
|
||||
}
|
||||
|
||||
return resource as ResourceInstance<TDefinition>
|
||||
}
|
||||
|
||||
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, string | number>): 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, unknown>): 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}` : ''
|
||||
}
|
||||
|
|
@ -12,14 +12,21 @@ import { InterceptorChain } from './interceptors.js'
|
|||
import { RequestMetrics } from './metrics.js'
|
||||
import { OfflineHandler } from './offline-handler.js'
|
||||
import { createHttpError, enhanceError, type ErrorContext } from './errors.js'
|
||||
import { prepareBodyPayload } from './body-utils.js'
|
||||
import { createResource, type ResourceBuilderOptions, type ResourceDefinition, type ResourceInstance } from './resource-builder.js'
|
||||
import { applyPresetChain, type TypedFetchPreset } from './presets.js'
|
||||
import { TypeDeclarationGenerator, type TypeSnapshotOptions } from '../discovery/type-generator.js'
|
||||
import type { TypeRegistry, TypedError } from '../types/index.js'
|
||||
import type { TypedFetchConfig } from '../types/config.js'
|
||||
import { DEFAULT_CONFIG, mergeConfig } from '../types/config.js'
|
||||
import { inferTypeDescriptor } from '../types/type-descriptor.js'
|
||||
|
||||
// Re-export configuration types for convenience
|
||||
export type { TypedFetchConfig } from '../types/config.js'
|
||||
export { DEFAULT_CONFIG, mergeConfig } from '../types/config.js'
|
||||
|
||||
const MAX_TYPE_SAMPLES = 5
|
||||
|
||||
export class RevolutionaryTypedFetch {
|
||||
private config: Required<TypedFetchConfig>
|
||||
private cache: WTinyLFUCache
|
||||
|
|
@ -64,6 +71,19 @@ export class RevolutionaryTypedFetch {
|
|||
// Always update baseURL from config
|
||||
this.baseURL = this.config.request.baseURL || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset configuration stacks for simple ergonomics
|
||||
*/
|
||||
use(...presetsInput: Array<TypedFetchPreset | TypedFetchPreset[]>): this {
|
||||
const flat = presetsInput.flat().filter(Boolean) as TypedFetchPreset[]
|
||||
if (!flat.length) return this
|
||||
const { overrides } = applyPresetChain(this.config, flat)
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.configure(overrides)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with custom configuration
|
||||
|
|
@ -74,18 +94,39 @@ export class RevolutionaryTypedFetch {
|
|||
}
|
||||
|
||||
// REAL runtime type tracking
|
||||
private recordRequest(endpoint: string, method: string, body: unknown): void {
|
||||
if (body === undefined) return
|
||||
const key = this.buildRegistryKey(method, endpoint)
|
||||
const entry = this.ensureRegistryEntry(key, method)
|
||||
entry.request = inferTypeDescriptor(body)
|
||||
entry.lastSeen = Date.now()
|
||||
}
|
||||
|
||||
private recordResponse(endpoint: string, method: string, data: any): void {
|
||||
const key = `${method.toUpperCase()} ${endpoint}`
|
||||
const key = this.buildRegistryKey(method, endpoint)
|
||||
this.typeInference.addSample(key, data)
|
||||
|
||||
// Update registry with inferred type
|
||||
this.typeRegistry[key] = {
|
||||
request: this.typeRegistry[key]?.request,
|
||||
response: this.typeInference.inferType(key),
|
||||
method: method.toUpperCase(),
|
||||
lastSeen: Date.now(),
|
||||
samples: [data]
|
||||
const entry = this.ensureRegistryEntry(key, method)
|
||||
const inferred = this.typeInference.inferType(key)
|
||||
if (inferred) {
|
||||
entry.response = inferred
|
||||
}
|
||||
entry.lastSeen = Date.now()
|
||||
entry.samples = [...entry.samples, data].slice(-MAX_TYPE_SAMPLES)
|
||||
}
|
||||
|
||||
private buildRegistryKey(method: string, endpoint: string): string {
|
||||
return `${method.toUpperCase()} ${endpoint}`
|
||||
}
|
||||
|
||||
private ensureRegistryEntry(key: string, method: string) {
|
||||
if (!this.typeRegistry[key]) {
|
||||
this.typeRegistry[key] = {
|
||||
method: method.toUpperCase(),
|
||||
lastSeen: Date.now(),
|
||||
samples: []
|
||||
}
|
||||
}
|
||||
return this.typeRegistry[key]!
|
||||
}
|
||||
|
||||
// REAL auto-discovery implementation
|
||||
|
|
@ -138,18 +179,54 @@ export class RevolutionaryTypedFetch {
|
|||
async get<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('GET', url, options)
|
||||
}
|
||||
|
||||
async post<T = unknown>(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('POST', url, { ...options, body: JSON.stringify(body) })
|
||||
|
||||
async post<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('POST', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('POST', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
}
|
||||
|
||||
async put<T = unknown>(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('PUT', url, { ...options, body: JSON.stringify(body) })
|
||||
|
||||
async put<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('PUT', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('PUT', url, { ...options, body: prepared.bodyInit }, prepared.sample)
|
||||
}
|
||||
|
||||
|
||||
async patch<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
body?: TBody,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
if (body === undefined) {
|
||||
return this.request<TResponse>('PATCH', url, options)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<TResponse>('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)
|
||||
}
|
||||
|
||||
resource<TDefinition extends ResourceDefinition>(
|
||||
path: string,
|
||||
definition: TDefinition,
|
||||
options?: ResourceBuilderOptions
|
||||
): ResourceInstance<TDefinition> {
|
||||
return createResource(this, path, definition, options)
|
||||
}
|
||||
|
||||
private resolveUrl(url: string): string {
|
||||
// Use baseURL from config or instance
|
||||
|
|
@ -165,7 +242,12 @@ export class RevolutionaryTypedFetch {
|
|||
}
|
||||
}
|
||||
|
||||
private async request<T>(method: string, url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
async request<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
bodySample?: unknown
|
||||
): Promise<{ data: T; response: Response }> {
|
||||
const fullUrl = this.resolveUrl(url)
|
||||
const cacheKey = `${method}:${fullUrl}`
|
||||
const startTime = performance.now()
|
||||
|
|
@ -210,6 +292,10 @@ export class RevolutionaryTypedFetch {
|
|||
|
||||
// Process through interceptors
|
||||
const processedOptions = await this.interceptors.processRequest(requestOptions)
|
||||
|
||||
if (bodySample !== undefined) {
|
||||
this.recordRequest(url, method, bodySample)
|
||||
}
|
||||
|
||||
// Handle offline requests
|
||||
const result = await this.offlineHandler.handleRequest(fullUrl, processedOptions, async () => {
|
||||
|
|
@ -361,6 +447,14 @@ export class RevolutionaryTypedFetch {
|
|||
getAllTypes(): TypeRegistry {
|
||||
return { ...this.typeRegistry }
|
||||
}
|
||||
|
||||
async exportTypes(options: TypeSnapshotOptions = {}): Promise<string> {
|
||||
const generator = new TypeDeclarationGenerator(this.typeRegistry)
|
||||
if (options.outFile) {
|
||||
return generator.writeToFile(options)
|
||||
}
|
||||
return generator.generate(options)
|
||||
}
|
||||
|
||||
getInferenceConfidence(endpoint: string): number {
|
||||
return this.typeInference.getConfidence(endpoint)
|
||||
|
|
@ -512,14 +606,14 @@ export class RevolutionaryTypedFetch {
|
|||
const chunkPromises = chunk.map(async (req) => {
|
||||
try {
|
||||
const method = req.method || 'GET'
|
||||
const result = await this.request<T>(
|
||||
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<T>(method, req.url, init, sample)
|
||||
return { data: result.data, response: result.response }
|
||||
} catch (error) {
|
||||
if (throwOnError) throw error
|
||||
|
|
@ -662,14 +756,14 @@ export class RevolutionaryTypedFetch {
|
|||
|
||||
const promises = requests.map(async (req, index) => {
|
||||
const method = req.method || 'GET'
|
||||
const result = await this.request<T>(
|
||||
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<T>(method, req.url, init, sample)
|
||||
return { ...result, winner: index }
|
||||
})
|
||||
|
||||
|
|
@ -711,14 +805,14 @@ export class RevolutionaryTypedFetch {
|
|||
requests.map(async (req) => {
|
||||
try {
|
||||
const method = req.method || 'GET'
|
||||
const result = await this.request<T>(
|
||||
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<T>(method, req.url, init, sample)
|
||||
return { data: result.data, response: result.response }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
|
|
|
|||
94
src/discovery/type-generator.ts
Normal file
94
src/discovery/type-generator.ts
Normal file
|
|
@ -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<string> {
|
||||
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))
|
||||
}
|
||||
13
src/index.ts
13
src/index.ts
|
|
@ -36,4 +36,15 @@ export { CircuitBreaker } from './core/circuit-breaker.js'
|
|||
export { InterceptorChain } from './core/interceptors.js'
|
||||
export { RequestMetrics } from './core/metrics.js'
|
||||
export { OfflineHandler } from './core/offline-handler.js'
|
||||
export { RequestDeduplicator } from './cache/deduplicator.js'
|
||||
export { RequestDeduplicator } from './cache/deduplicator.js'
|
||||
export { createResource } from './core/resource-builder.js'
|
||||
export { TypeDeclarationGenerator } from './discovery/type-generator.js'
|
||||
export { presets } from './core/presets.js'
|
||||
export type { TypedFetchPreset } from './core/presets.js'
|
||||
export type {
|
||||
ResourceBuilderOptions,
|
||||
ResourceDefinition,
|
||||
ResourceInstance,
|
||||
ResourceMethodConfig
|
||||
} from './core/resource-builder.js'
|
||||
export type { TypeSnapshotOptions } from './discovery/type-generator.js'
|
||||
|
|
@ -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> = T extends string
|
||||
? string
|
||||
|
|
@ -22,14 +24,16 @@ export type DeepPartial<T> = {
|
|||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue