@follow-app/client-sdk
Version:
TypeScript client SDK for Follow RSS Server API
263 lines (225 loc) • 7.38 kB
text/typescript
import type { ModuleDefinition } from "../shared/define-module"
import { RouteResolver } from "../shared/route-resolver"
import type {
HTTPMethod,
RequestContentType,
RequestOptions,
ResponseContentType,
} from "../types"
import type { HttpClient } from "./base"
/**
* Route function arguments for legacy compatibility
*/
export interface LegacyRouteArgs {
params?: Record<string, string>
query?: Record<string, string | number | boolean>
body?: unknown
headers?: Record<string, string>
timeout?: number
signal?: AbortSignal
}
/**
* Route function arguments with flattened input
*/
export interface RouteArgs extends Record<string, any> {
headers?: Record<string, string>
timeout?: number
signal?: AbortSignal
}
/**
* Route function type
*/
export type RouteFunction<T = any> = (args?: RouteArgs) => Promise<T>
/**
* Route definition for API endpoints (from module system)
*/
export interface RouteDefinition {
method: HTTPMethod
path: string
params?: readonly string[]
/** Fields that should be sent as query parameters */
query?: readonly string[]
/** Fields that should be sent as request body */
body?: readonly string[]
requestType?: RequestContentType
responseType?: ResponseContentType
}
/**
* Dynamic proxy handler for API routes
*/
export class APIProxyHandler {
private routes: Map<string, RouteDefinition>
private client: HttpClient
constructor(client: HttpClient, routes: Map<string, RouteDefinition>) {
this.client = client
this.routes = routes
}
/**
* Proxy get trap that creates route functions dynamically
*/
get(target: any, propKey: string | symbol): any {
const routeKey = String(propKey)
// Handle special properties
if (routeKey === "constructor" || routeKey === "prototype") {
return target[propKey]
}
// Handle nested routes (e.g., feeds.claim.challenge)
if (routeKey.includes(".")) {
return this.handleNestedRoute(routeKey)
}
const route = this.routes.get(routeKey)
if (!route) {
// For nested routes, return a proxy that continues the chain
if (this.hasNestedRoute(routeKey)) {
return this.createNestedProxy(routeKey)
}
throw new Error(`Route '${routeKey}' not found`)
}
return this.createRouteFunction(route)
}
/**
* Check if a route key has nested routes
*/
private hasNestedRoute(prefix: string): boolean {
for (const routeKey of this.routes.keys()) {
if (routeKey.startsWith(`${prefix}.`)) {
return true
}
}
return false
}
/**
* Create a nested proxy for route chains
*/
private createNestedProxy(prefix: string): any {
const nestedRoutes = new Map<string, RouteDefinition>()
// Find all routes that start with the prefix
for (const [routeKey, route] of this.routes.entries()) {
if (routeKey.startsWith(`${prefix}.`)) {
const nestedKey = routeKey.slice(Math.max(0, prefix.length + 1))
nestedRoutes.set(nestedKey, route)
}
}
return new Proxy({}, new APIProxyHandler(this.client, nestedRoutes))
}
/**
* Handle nested route calls
*/
private handleNestedRoute(routeKey: string): RouteFunction {
const route = this.routes.get(routeKey)
if (!route) {
throw new Error(`Nested route '${routeKey}' not found`)
}
return this.createRouteFunction(route)
}
/**
* Create a route function from a route definition
*/
private createRouteFunction(route: RouteDefinition): RouteFunction {
return async (args: RouteArgs = {}) => {
const { headers, timeout, signal, ...inputArgs } = args
// Check if this is using the old API structure (legacy compatibility)
const params: Record<string, string> = {}
const query: Record<string, string | number | boolean> = {}
let body: unknown
// Handle new flattened API structure
const flattenedArgs = { ...inputArgs }
// Extract parameters from flattenedArgs based on route definition
if (route.params) {
route.params.forEach((param) => {
if (flattenedArgs[param] !== undefined) {
params[param] = String(flattenedArgs[param])
// Remove param from flattenedArgs to avoid duplication
delete flattenedArgs[param]
}
})
}
// Extract query parameters based on route definition
if (route.query) {
route.query.forEach((field) => {
if (flattenedArgs[field] !== undefined) {
query[field] = flattenedArgs[field]
// Remove from flattenedArgs to avoid duplication
delete flattenedArgs[field]
}
})
}
// Extract body parameters based on route definition
if (route.body) {
const bodyData: Record<string, any> = {}
route.body.forEach((field) => {
if (flattenedArgs[field] !== undefined) {
bodyData[field] = flattenedArgs[field]
// Remove from flattenedArgs to avoid duplication
delete flattenedArgs[field]
}
})
if (Object.keys(bodyData).length > 0) {
body = bodyData
}
}
// Handle remaining fields with fallback logic (backward compatibility)
// For GET requests, remaining args go to query
// For POST/PUT/PATCH/DELETE requests, remaining args go to body
// Filter out undefined values
const remainingFields = Object.keys(flattenedArgs).filter(
(key) => flattenedArgs[key] !== undefined,
)
if (remainingFields.length > 0) {
const remainingData = Object.fromEntries(
remainingFields.map((key) => [key, flattenedArgs[key]]),
)
if (route.method === "GET") {
Object.assign(query, remainingData)
} else {
// For non-GET requests, merge remaining args into body
if (body && typeof body === "object") {
Object.assign(body, remainingData)
} else {
body = remainingData
}
}
}
// Build URL with parameters
let url = route.path
if (route.params) {
route.params.forEach((param) => {
const value = params[param]
if (value !== undefined) {
url = url.replace(`{${param}}`, encodeURIComponent(value))
}
})
}
// Build request options
const requestOptions: RequestOptions = {
method: route.method,
headers,
timeout,
signal,
requestType: route.requestType,
}
// Add query parameters if they exist
if (Object.keys(query).length > 0) {
requestOptions.query = query
}
// Add body if it exists
if (body !== undefined) {
requestOptions.body = body
}
return this.client.request(url, requestOptions)
}
}
}
/**
* Create a typed API proxy from a module definition
*/
export function createAPIProxy<T extends Record<string, any>>(
client: HttpClient,
module: ModuleDefinition<any>,
): T {
// Flatten nested routes and apply prefix
const flatRoutes = RouteResolver.flattenRoutes(module.routes, module.prefix)
const routeMap = new Map(Object.entries(flatRoutes))
return new Proxy({}, new APIProxyHandler(client, routeMap)) as T
}
// Legacy functions removed - new module system only