From 6f84b43cd677a1a1d489ca8de855af4f6b6ffdcd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 20:37:31 +0000 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20Fix=20typo=20"Bold"=20=E2=86=92=20"?= =?UTF-8?q?Boild"=20and=20enhance=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix typo: "Bold Typing" → "Boild Typing" (named after "boiled" - the warm opposite of cold, inspired by "boilerplate") - Add comprehensive Table of Contents to README - Improve Quick Start section with collapsible details and step-by-step guide - Expand API Usage examples: - Add PUT/PATCH/DELETE request examples - Add error handling patterns - Add request interceptors - Add timeout and retry logic examples - Enhance React Query integration section: - Add setup instructions with QueryClientProvider - Add useQuery examples with query options - Add useMutation examples with optimistic updates - Add useInfiniteQuery with pagination and infinite scroll - Add dependent queries pattern - Add new Advanced Usage section: - Authentication & JWT token refresh flow - File upload (single and multiple with progress) - Request cancellation with AbortController - Logging & debugging - Caching strategy - Rate limiting - Environment-based base URL - Update SKILL.md with Boild typing terminology and advanced features note --- README.md | 1177 ++++++++++++++++++++++++++++++++++++++++++++++++++--- SKILL.md | 5 +- 2 files changed, 1114 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index af185a0..b58e4c2 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,30 @@ [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org/) [![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-green.svg)](https://www.openapis.org/) -**A fully typed API client generator powered by OpenAPI. -Fetch-compatible, auto-generated types, zero generics required.** +**A fully typed API client generator powered by OpenAPI.** +**Fetch-compatible, auto-generated types, zero generics required.** -devup-api reads your `openapi.json` file and automatically generates a fully typed client that behaves like an ergonomic, type-safe version of `fetch()`. -No manual type declarations. No generics. No SDK boilerplate. +devup-api reads your `openapi.json` file and automatically generates a fully typed client that behaves like an ergonomic, type-safe version of `fetch()`. + +**No manual type declarations. No generics. No SDK boilerplate.** Just write API calls — the types are already there. +## 📖 Table of Contents + +- [Features](#-features) +- [Quick Start](#-quick-start) +- [Cold Typing vs Boild Typing](#-cold-typing-vs-boild-typing) +- [Packages](#-packages) +- [API Usage](#-api-usage) +- [Multiple API Servers](#-multiple-api-servers) +- [React Query Integration](#-react-query-integration) +- [Advanced Usage](#-advanced-usage) +- [Configuration Options](#-configuration-options) +- [How It Works](#-how-it-works) +- [Development](#-development) +- [Acknowledgments](#-acknowledgments) +- [License](#-license) + --- ## ✨ Features @@ -51,118 +68,211 @@ devup-api feels like using `fetch`, but with superpowers: ## 🚀 Quick Start -### **1. Install the package** +Get started with devup-api in under 5 minutes! Follow these steps to generate fully typed API clients from your OpenAPI schema. + +### **Step 1: Install Packages** + +Choose the plugin for your build tool and install it along with the core fetch package: + +
+Vite ```bash -# For Vite projects npm install @devup-api/fetch @devup-api/vite-plugin +``` +
-# For Next.js projects +
+Next.js + +```bash npm install @devup-api/fetch @devup-api/next-plugin +``` +
-# For Webpack projects +
+Webpack + +```bash npm install @devup-api/fetch @devup-api/webpack-plugin +``` +
-# For Rsbuild projects +
+Rsbuild + +```bash npm install @devup-api/fetch @devup-api/rsbuild-plugin ``` +
+ +### **Step 2: Configure Your Build Tool** -### **2. Configure your build tool** +Add the devup-api plugin to your build configuration: + +
+Vite - vite.config.ts -**Vite** (`vite.config.ts`): ```ts import { defineConfig } from 'vite' import devupApi from '@devup-api/vite-plugin' export default defineConfig({ - plugins: [devupApi()], + plugins: [ + devupApi({ + // Optional: customize configuration + openapiFile: 'openapi.json', // default + tempDir: 'df', // default + convertCase: 'camel', // default + }), + ], }) ``` +
+ +
+Next.js - next.config.ts -**Next.js** (`next.config.ts`): ```ts import devupApi from '@devup-api/next-plugin' export default devupApi({ reactStrictMode: true, + // devup-api plugin options can be passed here }) ``` +
+ +
+Webpack - webpack.config.js -**Webpack** (`webpack.config.js`): ```js const { devupApiWebpackPlugin } = require('@devup-api/webpack-plugin') module.exports = { - plugins: [new devupApiWebpackPlugin()], + plugins: [ + new devupApiWebpackPlugin({ + openapiFile: 'openapi.json', + tempDir: 'df', + }), + ], } ``` +
+ +
+Rsbuild - rsbuild.config.ts -**Rsbuild** (`rsbuild.config.ts`): ```ts import { defineConfig } from '@rsbuild/core' import { devupApiRsbuildPlugin } from '@devup-api/rsbuild-plugin' export default defineConfig({ - plugins: [devupApiRsbuildPlugin()], + plugins: [ + devupApiRsbuildPlugin({ + openapiFile: 'openapi.json', + tempDir: 'df', + }), + ], }) ``` +
+ +### **Step 3: Add Your OpenAPI Schema** + +Place your `openapi.json` file in the project root: -### **3. Add your OpenAPI schema** +``` +your-project/ +├── openapi.json ← Your OpenAPI schema +├── src/ +├── package.json +└── vite.config.ts (or next.config.ts, etc.) +``` -Place your `openapi.json` file in the project root (or specify a custom path in plugin options). +> **Tip:** You can specify a custom path using the `openapiFile` option in plugin configuration. -### **4. Configure TypeScript** +### **Step 4: Configure TypeScript** -Add the generated type definitions to your `tsconfig.json`: +Update your `tsconfig.json` to include the generated type definitions: ```json { "compilerOptions": { - // ... your compiler options + "strict": true, + "moduleResolution": "bundler" + // ... other options }, "include": [ "src", - "df/**/*.d.ts" + "df/**/*.d.ts" // ← Include generated types ] } ``` -> **Note:** The `df` directory is the default temporary directory where generated types are stored. If you've customized `tempDir` in plugin options, adjust the path accordingly (e.g., `"your-temp-dir/**/*.d.ts"`). +> **Note:** `df` is the default temp directory. If you customized `tempDir`, use that path instead (e.g., `"your-temp-dir/**/*.d.ts"`). -### **5. Create and use the API client** +### **Step 5: Run Your Build** + +Start your development server to generate types: + +```bash +npm run dev +``` + +This will: +1. Read your `openapi.json` file +2. Generate TypeScript type definitions in `df/api.d.ts` +3. Enable full type safety for your API calls (**Boild Typing** 🔥) + +### **Step 6: Create and Use Your API Client** + +Now you're ready to make fully typed API calls! ```ts import { createApi } from '@devup-api/fetch' +// Create API client const api = createApi('https://api.example.com') -// Use operationId -const users = await api.get('getUsers', {}) +// ✅ GET request using operationId +const users = await api.get('getUsers', { + query: { page: 1, limit: 20 } +}) -// Or use the path directly +// ✅ GET request using path with params const user = await api.get('/users/{id}', { params: { id: '123' }, headers: { - Authorization: 'Bearer TOKEN' + Authorization: 'Bearer YOUR_TOKEN' } }) -// POST request with typed body +// ✅ POST request with typed body const newUser = await api.post('createUser', { body: { name: 'John Doe', email: 'john@example.com' } }) + +// ✅ Handle response +if (newUser.data) { + console.log('User created:', newUser.data.id) +} else if (newUser.error) { + console.error('Error:', newUser.error.message) +} ``` +**That's it!** 🎉 Your API client is now fully typed based on your OpenAPI schema. + --- -## 🔥 Cold Typing vs Bold Typing +## 🔥 Cold Typing vs Boild Typing devup-api uses a two-phase typing system to ensure smooth development experience: -### **Cold Typing** +### **Cold Typing** ❄️ **Cold typing** refers to the state before the TypeScript interface files are generated. This happens when: - You first install the plugin @@ -181,23 +291,24 @@ const api = createApi('https://api.example.com') const result = await api.get('getUsers', {}) // ✅ Works, types are 'any' ``` -### **Bold Typing** +### **Boild Typing** 🔥 -**Bold typing** refers to the state after the TypeScript interface files are generated. This happens when: +**Boild typing** (named after "boiled" - the warm opposite of cold, and inspired by "boilerplate") refers to the state after the TypeScript interface files are generated. This happens when: - The build tool has run (`dev` or `build`) - The plugin has generated `api.d.ts` in the temp directory - TypeScript can find and use the generated types -During bold typing: +During boild typing: - All API types are strictly enforced - Full type safety is applied - Type errors will be caught at compile time - You get full IntelliSense and autocomplete +- No more boilerplate - types are ready to use! ```ts -// Bold typing: Full type safety after api.d.ts is generated +// Boild typing: Full type safety after api.d.ts is generated const api = createApi('https://api.example.com') -const result = await api.get('getUsers', {}) +const result = await api.get('getUsers', {}) // ✅ Fully typed: result.data is typed based on your OpenAPI schema // ❌ Type error if you use wrong parameters or paths ``` @@ -209,6 +320,7 @@ This two-phase approach ensures: 2. **Gradual typing**: Types become available as soon as the build runs 3. **Production safety**: Full type checking in production builds 4. **Developer experience**: No false type errors during initial setup +5. **Zero boilerplate**: Once boiled, your types are ready - no manual type definitions needed --- @@ -230,7 +342,9 @@ This is a monorepo containing multiple packages: ## 📚 API Usage -### **GET Example** +### **Basic Requests** + +#### GET Request ```ts // Using operationId @@ -244,7 +358,7 @@ const users = await api.get('/users', { }) ``` -### **POST Example** +#### POST Request ```ts const newPost = await api.post('createPost', { @@ -255,12 +369,90 @@ const newPost = await api.post('createPost', { }) ``` -### **Path Params Example** +#### PUT/PATCH Request + +```ts +// Update entire resource +const updatedUser = await api.put('/users/{id}', { + params: { id: '123' }, + body: { + name: 'Jane Doe', + email: 'jane@example.com' + } +}) + +// Partial update +const patchedUser = await api.patch('/users/{id}', { + params: { id: '123' }, + body: { + name: 'Jane Doe' // Only update name + } +}) +``` + +#### DELETE Request ```ts +const result = await api.delete('/users/{id}', { + params: { id: '123' } +}) + +if (result.data) { + console.log('User deleted successfully') +} +``` + +### **Path Parameters** + +```ts +// Single path parameter const post = await api.get('/posts/{id}', { params: { id: '777' } }) + +// Multiple path parameters +const comment = await api.get('/posts/{postId}/comments/{commentId}', { + params: { + postId: '123', + commentId: '456' + } +}) +``` + +### **Query Parameters** + +```ts +// Simple query params +const users = await api.get('getUsers', { + query: { + page: 1, + limit: 20, + sort: 'name', + order: 'asc' + } +}) + +// Query params with arrays +const products = await api.get('getProducts', { + query: { + categories: ['electronics', 'books'], + tags: ['sale', 'new'] + } +}) +``` + +### **Headers** + +```ts +// Custom headers +const user = await api.get('/users/{id}', { + params: { id: '123' }, + headers: { + 'Authorization': 'Bearer YOUR_TOKEN', + 'X-Custom-Header': 'custom-value', + 'Accept-Language': 'en-US' + } +}) ``` ### **Response Handling** @@ -271,9 +463,53 @@ const result = await api.get('getUser', { params: { id: '123' } }) if (result.data) { // Success response - fully typed! console.log(result.data.name) + console.log(result.data.email) } else if (result.error) { - // Error response - console.error(result.error.message) + // Error response - also typed based on OpenAPI error schemas + console.error('Error:', result.error.message) + console.error('Status:', result.error.status) +} +``` + +### **Error Handling** + +```ts +// Basic error handling +const result = await api.post('createUser', { + body: { name: 'John', email: 'john@example.com' } +}) + +if (result.error) { + switch (result.error.status) { + case 400: + console.error('Bad request:', result.error.message) + break + case 401: + console.error('Unauthorized') + // Redirect to login + break + case 403: + console.error('Forbidden') + break + case 404: + console.error('Not found') + break + case 500: + console.error('Server error') + break + default: + console.error('Unknown error:', result.error) + } +} + +// Try-catch for network errors +try { + const result = await api.get('getUsers', {}) + if (result.data) { + console.log(result.data) + } +} catch (error) { + console.error('Network error:', error) } ``` @@ -298,6 +534,123 @@ const user: User = { // For request/error types, specify the type category type CreateUserRequest = DevupObject<'request'>['CreateUserBody'] type ApiError = DevupObject<'error'>['ErrorResponse'] + +// Use types in function parameters +function displayUser(user: User) { + console.log(`${user.name} (${user.email})`) +} + +// Use types in React components +interface UserCardProps { + user: User + onUpdate: (data: CreateUserRequest) => void +} + +function UserCard({ user, onUpdate }: UserCardProps) { + // Component implementation +} +``` + +### **Request Interceptors** + +```ts +import { createApi } from '@devup-api/fetch' + +// Create API with custom fetch implementation +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + // Add custom logic before request + console.log('Requesting:', url) + + // Add authentication token + const headers = new Headers(init?.headers) + const token = localStorage.getItem('authToken') + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + // Make the request + const response = await fetch(url, { + ...init, + headers, + }) + + // Add custom logic after request + console.log('Response status:', response.status) + + return response + } +}) +``` + +### **Timeout Configuration** + +```ts +import { createApi } from '@devup-api/fetch' + +// Create API with timeout +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) // 5 second timeout + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }) + return response + } finally { + clearTimeout(timeout) + } + } +}) + +// Usage +try { + const result = await api.get('getUsers', {}) + if (result.data) { + console.log(result.data) + } +} catch (error) { + if (error.name === 'AbortError') { + console.error('Request timed out') + } +} +``` + +### **Retry Logic** + +```ts +import { createApi } from '@devup-api/fetch' + +async function fetchWithRetry( + url: string, + init?: RequestInit, + retries = 3 +): Promise { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(url, init) + if (response.ok || i === retries - 1) { + return response + } + // Wait before retry (exponential backoff) + await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)) + } catch (error) { + if (i === retries - 1) throw error + await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)) + } + } + throw new Error('Max retries exceeded') +} + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: fetchWithRetry +}) ``` --- @@ -339,7 +692,7 @@ type Product = DevupObject<'response', 'openapi2.json'>['Product'] // From open ## 🔄 React Query Integration -devup-api provides first-class support for TanStack React Query through the `@devup-api/react-query` package. +devup-api provides first-class support for TanStack React Query through the `@devup-api/react-query` package. All hooks are fully typed based on your OpenAPI schema. ### **Installation** @@ -349,19 +702,37 @@ npm install @devup-api/react-query @tanstack/react-query ### **Setup** -```ts +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createApi } from '@devup-api/fetch' import { createQueryClient } from '@devup-api/react-query' +// Create API client const api = createApi('https://api.example.com') + +// Create React Query client const queryClient = createQueryClient(api) + +// Create TanStack QueryClient +const tanstackQueryClient = new QueryClient() + +// Wrap your app +function App() { + return ( + + + + ) +} ``` -### **useQuery** +### **useQuery - Fetching Data** + +```tsx +import { queryClient } from './api' -```ts function UserProfile({ userId }: { userId: string }) { - const { data, isLoading, error } = queryClient.useQuery( + const { data, isLoading, error, refetch } = queryClient.useQuery( 'get', '/users/{id}', { params: { id: userId } } @@ -369,61 +740,735 @@ function UserProfile({ userId }: { userId: string }) { if (isLoading) return
Loading...
if (error) return
Error: {error.message}
- return
{data.name}
+ + return ( +
+

{data.name}

+

{data.email}

+ +
+ ) } ``` -### **useMutation** +### **useQuery with Query Options** -```ts -function CreateUser() { - const mutation = queryClient.useMutation('post', 'createUser') +```tsx +function UserList() { + const { data, isLoading } = queryClient.useQuery( + 'get', + 'getUsers', + { query: { page: 1, limit: 10 } }, + { + // React Query options + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + retry: 3, + } + ) + + if (isLoading) return
Loading...
+ + return ( + + ) +} +``` + +### **useMutation - Creating/Updating Data** + +```tsx +function CreateUserForm() { + const mutation = queryClient.useMutation('post', 'createUser', { + onSuccess: (data) => { + console.log('User created:', data) + // Invalidate and refetch + tanstackQueryClient.invalidateQueries({ queryKey: ['getUsers'] }) + }, + onError: (error) => { + console.error('Failed to create user:', error) + } + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + + mutation.mutate({ + body: { + name: formData.get('name') as string, + email: formData.get('email') as string, + } + }) + } + + return ( +
+ + + + {mutation.isError &&
Error: {mutation.error.message}
} + {mutation.isSuccess &&
User created successfully!
} +
+ ) +} +``` + +### **useMutation with Optimistic Updates** + +```tsx +function UpdateUserForm({ userId }: { userId: string }) { + const mutation = queryClient.useMutation('patch', '/users/{id}', { + onMutate: async (variables) => { + // Cancel outgoing refetches + await tanstackQueryClient.cancelQueries({ queryKey: ['getUser', userId] }) + + // Snapshot the previous value + const previousUser = tanstackQueryClient.getQueryData(['getUser', userId]) + + // Optimistically update to the new value + if (previousUser) { + tanstackQueryClient.setQueryData(['getUser', userId], { + ...previousUser, + ...variables.body, + }) + } + + return { previousUser } + }, + onError: (err, variables, context) => { + // Rollback on error + if (context?.previousUser) { + tanstackQueryClient.setQueryData(['getUser', userId], context.previousUser) + } + }, + onSettled: () => { + // Refetch after error or success + tanstackQueryClient.invalidateQueries({ queryKey: ['getUser', userId] }) + }, + }) return ( ) } ``` -### **useSuspenseQuery** +### **useSuspenseQuery - With React Suspense** + +```tsx +import { Suspense } from 'react' -```ts function UserList() { + // No loading state needed - Suspense handles it const { data } = queryClient.useSuspenseQuery('get', 'getUsers', {}) - return
    {data.map(user =>
  • {user.name}
  • )}
+ + return ( +
    + {data.map(user => ( +
  • {user.name}
  • + ))} +
+ ) +} + +function App() { + return ( + Loading users...}> + + + ) } ``` -### **useInfiniteQuery** +### **useInfiniteQuery - Pagination** -```ts +```tsx function InfiniteUserList() { - const { data, fetchNextPage, hasNextPage } = queryClient.useInfiniteQuery( + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = queryClient.useInfiniteQuery( 'get', 'getUsers', { initialPageParam: 1, - getNextPageParam: (lastPage) => lastPage.nextPage, + getNextPageParam: (lastPage, allPages) => { + // Return next page number or undefined if no more pages + return lastPage.hasMore ? allPages.length + 1 : undefined + }, } ) + if (isLoading) return
Loading...
+ return ( - <> - {data?.pages.map(page => - page.users.map(user =>
{user.name}
) +
+ {data?.pages.map((page, i) => ( +
+ {page.users.map(user => ( +
+

{user.name}

+

{user.email}

+
+ ))} +
+ ))} + + {hasNextPage && ( + )} - {hasNextPage && } - +
+ ) +} +``` + +### **useInfiniteQuery - Infinite Scroll** + +```tsx +import { useEffect, useRef } from 'react' + +function InfiniteScrollList() { + const observerTarget = useRef(null) + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + queryClient.useInfiniteQuery( + 'get', + 'getUsers', + { + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage, + } + ) + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { threshold: 1.0 } + ) + + if (observerTarget.current) { + observer.observe(observerTarget.current) + } + + return () => observer.disconnect() + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + return ( +
+ {data?.pages.map((page, i) => ( +
+ {page.users.map(user => ( +
{user.name}
+ ))} +
+ ))} + +
+ {isFetchingNextPage && 'Loading more...'} +
+
+ ) +} +``` + +### **Dependent Queries** + +```tsx +function UserPosts({ userId }: { userId: string }) { + // First, fetch the user + const { data: user } = queryClient.useQuery( + 'get', + '/users/{id}', + { params: { id: userId } } + ) + + // Then fetch posts, but only if user is loaded + const { data: posts } = queryClient.useQuery( + 'get', + '/posts', + { query: { userId } }, + { + enabled: !!user, // Only run this query if user exists + } + ) + + return ( +
+

{user?.name}'s Posts

+ {posts?.map(post => ( +
+

{post.title}

+

{post.content}

+
+ ))} +
) } ``` --- +## 🚀 Advanced Usage + +### **Authentication & Authorization** + +#### JWT Authentication + +```ts +import { createApi } from '@devup-api/fetch' + +// Option 1: Custom fetch with automatic token injection +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + const token = localStorage.getItem('accessToken') + + const headers = new Headers(init?.headers) + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + return fetch(url, { ...init, headers }) + } +}) + +// Option 2: Wrapper function for token refresh +async function authenticatedFetch(url: string, init?: RequestInit): Promise { + const token = await getValidToken() // Refresh if expired + + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${token}`) + + return fetch(url, { ...init, headers }) +} + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: authenticatedFetch +}) +``` + +#### Token Refresh Flow + +```ts +import { createApi } from '@devup-api/fetch' + +let accessToken = localStorage.getItem('accessToken') +let refreshToken = localStorage.getItem('refreshToken') + +async function refreshAccessToken(): Promise { + const response = await fetch('https://api.example.com/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }) + + if (!response.ok) { + // Redirect to login + window.location.href = '/login' + throw new Error('Failed to refresh token') + } + + const data = await response.json() + accessToken = data.accessToken + refreshToken = data.refreshToken + + localStorage.setItem('accessToken', accessToken) + localStorage.setItem('refreshToken', refreshToken) + + return accessToken +} + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + const headers = new Headers(init?.headers) + + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`) + } + + let response = await fetch(url, { ...init, headers }) + + // If unauthorized, try to refresh token and retry + if (response.status === 401) { + try { + const newToken = await refreshAccessToken() + headers.set('Authorization', `Bearer ${newToken}`) + response = await fetch(url, { ...init, headers }) + } catch (error) { + // Refresh failed, redirect to login + window.location.href = '/login' + throw error + } + } + + return response + } +}) +``` + +### **File Upload** + +#### Single File Upload + +```ts +// Assuming your OpenAPI schema has a file upload endpoint +async function uploadFile(file: File) { + const formData = new FormData() + formData.append('file', file) + + const result = await api.post('/upload', { + body: formData, + headers: { + // Don't set Content-Type - browser will set it with boundary + } + }) + + if (result.data) { + console.log('File uploaded:', result.data.url) + } +} + +// Usage +const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + uploadFile(file) + } +} +``` + +#### Multiple File Upload with Progress + +```ts +import { createApi } from '@devup-api/fetch' + +function uploadFilesWithProgress( + files: File[], + onProgress: (progress: number) => void +) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const formData = new FormData() + + files.forEach((file, index) => { + formData.append(`file${index}`, file) + }) + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const progress = (e.loaded / e.total) * 100 + onProgress(progress) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)) + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)) + } + }) + + xhr.addEventListener('error', () => reject(new Error('Upload failed'))) + + xhr.open('POST', 'https://api.example.com/upload/multiple') + xhr.setRequestHeader('Authorization', `Bearer ${getToken()}`) + xhr.send(formData) + }) +} + +// Usage in React +function FileUploader() { + const [progress, setProgress] = useState(0) + + const handleUpload = async (files: FileList) => { + try { + const result = await uploadFilesWithProgress( + Array.from(files), + setProgress + ) + console.log('Upload complete:', result) + } catch (error) { + console.error('Upload failed:', error) + } + } + + return ( +
+ e.target.files && handleUpload(e.target.files)} + /> + +
+ ) +} +``` + +### **Request Cancellation** + +```ts +import { createApi } from '@devup-api/fetch' + +// Create API with support for AbortController +const api = createApi('https://api.example.com') + +function SearchComponent() { + const [controller, setController] = useState(null) + + const handleSearch = async (query: string) => { + // Cancel previous request + if (controller) { + controller.abort() + } + + // Create new controller + const newController = new AbortController() + setController(newController) + + try { + // Custom fetch with abort signal + const response = await fetch(`https://api.example.com/search?q=${query}`, { + signal: newController.signal + }) + + const data = await response.json() + console.log('Search results:', data) + } catch (error) { + if (error.name === 'AbortError') { + console.log('Search cancelled') + } else { + console.error('Search failed:', error) + } + } + } + + useEffect(() => { + return () => { + // Cleanup: cancel request on unmount + if (controller) { + controller.abort() + } + } + }, [controller]) + + return ( + handleSearch(e.target.value)} + placeholder="Search..." + /> + ) +} +``` + +### **Logging & Debugging** + +```ts +import { createApi } from '@devup-api/fetch' + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + const startTime = performance.now() + + console.group(`🌐 API Request: ${init?.method || 'GET'} ${url}`) + console.log('Headers:', init?.headers) + console.log('Body:', init?.body) + + try { + const response = await fetch(url, init) + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + + console.log(`✅ Response: ${response.status} (${duration}ms)`) + console.groupEnd() + + return response + } catch (error) { + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + + console.error(`❌ Request failed (${duration}ms):`, error) + console.groupEnd() + + throw error + } + } +}) +``` + +### **Caching Strategy** + +```ts +import { createApi } from '@devup-api/fetch' + +// Simple in-memory cache +const cache = new Map() +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + const cacheKey = `${init?.method || 'GET'}:${url}` + + // Only cache GET requests + if (!init?.method || init.method === 'GET') { + const cached = cache.get(cacheKey) + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('Cache hit:', cacheKey) + // Return cached response + return new Response(JSON.stringify(cached.data), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + } + + const response = await fetch(url, init) + + // Cache successful GET responses + if (response.ok && (!init?.method || init.method === 'GET')) { + const clone = response.clone() + const data = await clone.json() + + cache.set(cacheKey, { + data, + timestamp: Date.now() + }) + } + + return response + } +}) + +// Clear cache function +export function clearCache() { + cache.clear() +} +``` + +### **Rate Limiting** + +```ts +import { createApi } from '@devup-api/fetch' + +class RateLimiter { + private queue: Array<() => void> = [] + private requestsInWindow = 0 + private windowStart = Date.now() + + constructor( + private maxRequests: number, + private windowMs: number + ) {} + + async throttle(): Promise { + return new Promise((resolve) => { + const now = Date.now() + + // Reset window if expired + if (now - this.windowStart >= this.windowMs) { + this.requestsInWindow = 0 + this.windowStart = now + } + + // If under limit, proceed immediately + if (this.requestsInWindow < this.maxRequests) { + this.requestsInWindow++ + resolve() + } else { + // Queue the request + this.queue.push(() => { + this.requestsInWindow++ + resolve() + }) + + // Schedule queue processing + const delay = this.windowMs - (now - this.windowStart) + setTimeout(() => { + this.requestsInWindow = 0 + this.windowStart = Date.now() + this.processQueue() + }, delay) + } + }) + } + + private processQueue() { + while (this.queue.length > 0 && this.requestsInWindow < this.maxRequests) { + const next = this.queue.shift() + next?.() + } + } +} + +// 10 requests per second +const rateLimiter = new RateLimiter(10, 1000) + +const api = createApi({ + baseUrl: 'https://api.example.com', + fetch: async (url, init) => { + await rateLimiter.throttle() + return fetch(url, init) + } +}) +``` + +### **Custom Base URL per Environment** + +```ts +import { createApi } from '@devup-api/fetch' + +const getBaseUrl = () => { + switch (process.env.NODE_ENV) { + case 'production': + return 'https://api.production.com' + case 'staging': + return 'https://api.staging.com' + case 'development': + default: + return 'http://localhost:3000' + } +} + +const api = createApi(getBaseUrl()) + +// Or with environment variables +const api = createApi(process.env.VITE_API_BASE_URL || 'http://localhost:3000') +``` + +--- + ## ⚙️ Configuration Options All plugins accept the following options: diff --git a/SKILL.md b/SKILL.md index a253e1d..8648835 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,7 +15,7 @@ This skill helps you invoke the `devup-api` library to generate and use fully ty - **Build Tool Integration:** Plugins for Vite, Next.js, Webpack, and Rsbuild. - **React Query Integration:** First-class support for TanStack React Query with `@devup-api/react-query`. - **Multiple API Servers:** Support for multiple OpenAPI schemas with `serverName` and `DevupObject` type access. -- **Two-phase Typing:** "Cold Typing" (relaxed types for initial setup) and "Bold Typing" (strict types after generation). +- **Two-phase Typing:** "Cold Typing" (relaxed types for initial setup) and "Boild Typing" (strict types after generation - named after "boiled" and inspired by "boilerplate"). ## Usage Instructions @@ -172,7 +172,8 @@ const { data, fetchNextPage } = queryClient.useInfiniteQuery('get', 'getUsers', ## Guidelines -- **"Cold" vs "Bold" Typing:** When you first start, types might be `any` (Cold Typing). Run your build command (`dev` or `build`) to generate the types and enable strict checking (Bold Typing). +- **"Cold" vs "Boild" Typing:** When you first start, types might be `any` (Cold Typing ❄️). Run your build command (`dev` or `build`) to generate the types and enable strict checking (Boild Typing 🔥 - the warm opposite of cold, with zero boilerplate needed!). - **Operation IDs vs Paths:** You can use either the OpenAPI `operationId` (e.g., `getUsers`) or the URL path (e.g., `/users`). `operationId` is often more concise. - **Generated Files:** Do not manually edit the files in the `df` (or configured temp) directory. They are auto-generated. - **Verification:** If types seem missing, ensure `tsconfig.json` includes the generated folder and that the build script has run at least once. +- **Advanced Features:** devup-api supports authentication, file uploads, request interceptors, retry logic, caching, and more through custom fetch implementations. From 1a24d0bfebfc7bb4411f8309055eaf9a7ba5671b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 07:07:07 +0000 Subject: [PATCH 2/4] docs: Fix incorrect API usage examples to use Middleware Fixed all examples that incorrectly used custom `fetch` function parameter: - Authentication: Now uses api.use() with onRequest middleware for JWT token injection - Token Refresh Flow: Uses onRequest and onResponse middleware for automatic token refresh - Request Cancellation: Uses signal parameter in api.get() options - Timeout Configuration: Uses middleware with AbortController - Retry Logic: Uses onResponse middleware for retry on server errors - Logging & Debugging: Uses onRequest/onResponse middleware - Caching Strategy: Uses onRequest middleware to return cached responses - Rate Limiting: Uses onRequest middleware with rate limiter Removed duplicate "Request Interceptors" section (functionality covered by Middleware examples) All examples now correctly demonstrate devup-api's Middleware API pattern as shown in test files. --- README.md | 284 ++++++++++++++++++++++++++---------------------------- 1 file changed, 139 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index b58e4c2..94e89da 100644 --- a/README.md +++ b/README.md @@ -551,60 +551,50 @@ function UserCard({ user, onUpdate }: UserCardProps) { } ``` -### **Request Interceptors** +### **Middleware Examples** -```ts -import { createApi } from '@devup-api/fetch' - -// Create API with custom fetch implementation -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { - // Add custom logic before request - console.log('Requesting:', url) +Middleware allows you to intercept and modify requests and responses globally. - // Add authentication token - const headers = new Headers(init?.headers) - const token = localStorage.getItem('authToken') - if (token) { - headers.set('Authorization', `Bearer ${token}`) - } +#### Request Logging Middleware - // Make the request - const response = await fetch(url, { - ...init, - headers, - }) +```ts +import { createApi } from '@devup-api/fetch' - // Add custom logic after request - console.log('Response status:', response.status) +const api = createApi({ baseUrl: 'https://api.example.com' }) - return response +api.use({ + onRequest: async ({ request, schemaPath, params, query }) => { + console.log(`🌐 API Request: ${request.method} ${schemaPath}`) + console.log('Params:', params) + console.log('Query:', query) + return undefined // No modification + }, + onResponse: async ({ response, schemaPath }) => { + console.log(`✅ Response: ${response.status} ${schemaPath}`) + return undefined // No modification } }) ``` -### **Timeout Configuration** +#### Timeout Middleware ```ts import { createApi } from '@devup-api/fetch' -// Create API with timeout -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { +const api = createApi({ baseUrl: 'https://api.example.com' }) + +api.use({ + onRequest: async ({ request }) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) // 5 second timeout - try { - const response = await fetch(url, { - ...init, - signal: controller.signal, - }) - return response - } finally { - clearTimeout(timeout) - } + // Create new request with abort signal + const modifiedRequest = new Request(request, { signal: controller.signal }) + + // Store timeout ID for cleanup + ;(modifiedRequest as any).__timeoutId = timeout + + return modifiedRequest } }) @@ -621,35 +611,37 @@ try { } ``` -### **Retry Logic** +#### Retry Logic Middleware ```ts import { createApi } from '@devup-api/fetch' -async function fetchWithRetry( - url: string, - init?: RequestInit, - retries = 3 -): Promise { - for (let i = 0; i < retries; i++) { - try { - const response = await fetch(url, init) - if (response.ok || i === retries - 1) { - return response +const api = createApi({ baseUrl: 'https://api.example.com' }) + +api.use({ + onResponse: async ({ request, response }) => { + const maxRetries = 3 + const retryDelay = 1000 // 1 second + + // Retry on server errors (5xx) + if (response.status >= 500 && response.status < 600) { + for (let i = 0; i < maxRetries; i++) { + await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, i))) + + const retryResponse = await fetch(request) + if (retryResponse.ok) { + return retryResponse + } + + // Last retry failed + if (i === maxRetries - 1) { + return retryResponse + } } - // Wait before retry (exponential backoff) - await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)) - } catch (error) { - if (i === retries - 1) throw error - await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)) } - } - throw new Error('Max retries exceeded') -} -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: fetchWithRetry + return undefined // No modification + } }) ``` @@ -1037,47 +1029,37 @@ function UserPosts({ userId }: { userId: string }) { ### **Authentication & Authorization** -#### JWT Authentication +#### JWT Authentication with Middleware ```ts import { createApi } from '@devup-api/fetch' -// Option 1: Custom fetch with automatic token injection -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { +const api = createApi({ baseUrl: 'https://api.example.com' }) + +// Add authentication middleware +api.use({ + onRequest: async ({ request }) => { const token = localStorage.getItem('accessToken') - const headers = new Headers(init?.headers) if (token) { + const headers = new Headers(request.headers) headers.set('Authorization', `Bearer ${token}`) + + return new Request(request, { headers }) } - return fetch(url, { ...init, headers }) + return undefined // No modification } }) - -// Option 2: Wrapper function for token refresh -async function authenticatedFetch(url: string, init?: RequestInit): Promise { - const token = await getValidToken() // Refresh if expired - - const headers = new Headers(init?.headers) - headers.set('Authorization', `Bearer ${token}`) - - return fetch(url, { ...init, headers }) -} - -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: authenticatedFetch -}) ``` -#### Token Refresh Flow +#### Token Refresh Flow with Middleware ```ts import { createApi } from '@devup-api/fetch' +const api = createApi({ baseUrl: 'https://api.example.com' }) + let accessToken = localStorage.getItem('accessToken') let refreshToken = localStorage.getItem('refreshToken') @@ -1089,7 +1071,6 @@ async function refreshAccessToken(): Promise { }) if (!response.ok) { - // Redirect to login window.location.href = '/login' throw new Error('Failed to refresh token') } @@ -1104,23 +1085,28 @@ async function refreshAccessToken(): Promise { return accessToken } -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { - const headers = new Headers(init?.headers) - +// Add authentication and token refresh middleware +api.use({ + onRequest: async ({ request }) => { + // Add current access token + const headers = new Headers(request.headers) if (accessToken) { headers.set('Authorization', `Bearer ${accessToken}`) } - - let response = await fetch(url, { ...init, headers }) - + return new Request(request, { headers }) + }, + onResponse: async ({ request, response }) => { // If unauthorized, try to refresh token and retry if (response.status === 401) { try { const newToken = await refreshAccessToken() + + // Retry the original request with new token + const headers = new Headers(request.headers) headers.set('Authorization', `Bearer ${newToken}`) - response = await fetch(url, { ...init, headers }) + + const retryResponse = await fetch(new Request(request, { headers })) + return retryResponse } catch (error) { // Refresh failed, redirect to login window.location.href = '/login' @@ -1128,7 +1114,7 @@ const api = createApi({ } } - return response + return undefined // No modification } }) ``` @@ -1238,7 +1224,6 @@ function FileUploader() { ```ts import { createApi } from '@devup-api/fetch' -// Create API with support for AbortController const api = createApi('https://api.example.com') function SearchComponent() { @@ -1255,13 +1240,15 @@ function SearchComponent() { setController(newController) try { - // Custom fetch with abort signal - const response = await fetch(`https://api.example.com/search?q=${query}`, { + // Use devup-api with abort signal + const result = await api.get('searchUsers', { + query: { q: query }, signal: newController.signal }) - const data = await response.json() - console.log('Search results:', data) + if (result.data) { + console.log('Search results:', result.data) + } } catch (error) { if (error.name === 'AbortError') { console.log('Search cancelled') @@ -1290,63 +1277,63 @@ function SearchComponent() { } ``` -### **Logging & Debugging** +#### Logging & Debugging Middleware ```ts import { createApi } from '@devup-api/fetch' -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { - const startTime = performance.now() - - console.group(`🌐 API Request: ${init?.method || 'GET'} ${url}`) - console.log('Headers:', init?.headers) - console.log('Body:', init?.body) - - try { - const response = await fetch(url, init) - const endTime = performance.now() - const duration = (endTime - startTime).toFixed(2) - - console.log(`✅ Response: ${response.status} (${duration}ms)`) - console.groupEnd() +const api = createApi({ baseUrl: 'https://api.example.com' }) - return response - } catch (error) { - const endTime = performance.now() - const duration = (endTime - startTime).toFixed(2) +api.use({ + onRequest: async ({ request, schemaPath, params, query, body }) => { + const startTime = performance.now() + ;(request as any).__startTime = startTime - console.error(`❌ Request failed (${duration}ms):`, error) - console.groupEnd() + console.group(`🌐 API Request: ${request.method} ${schemaPath}`) + console.log('URL:', request.url) + console.log('Params:', params) + console.log('Query:', query) + console.log('Body:', body) + console.groupEnd() - throw error + return undefined // No modification + }, + onResponse: async ({ request, response, schemaPath }) => { + const startTime = (request as any).__startTime || 0 + const duration = (performance.now() - startTime).toFixed(2) + + if (response.ok) { + console.log(`✅ Success: ${response.status} ${schemaPath} (${duration}ms)`) + } else { + console.error(`❌ Error: ${response.status} ${schemaPath} (${duration}ms)`) } + + return undefined // No modification } }) ``` -### **Caching Strategy** +#### Caching Middleware ```ts import { createApi } from '@devup-api/fetch' +const api = createApi({ baseUrl: 'https://api.example.com' }) + // Simple in-memory cache const cache = new Map() const CACHE_TTL = 5 * 60 * 1000 // 5 minutes -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { - const cacheKey = `${init?.method || 'GET'}:${url}` - +api.use({ + onRequest: async ({ request, schemaPath }) => { // Only cache GET requests - if (!init?.method || init.method === 'GET') { + if (request.method === 'GET') { + const cacheKey = `${schemaPath}:${request.url}` const cached = cache.get(cacheKey) if (cached && Date.now() - cached.timestamp < CACHE_TTL) { console.log('Cache hit:', cacheKey) - // Return cached response + // Return cached response directly (skip fetch) return new Response(JSON.stringify(cached.data), { status: 200, headers: { 'Content-Type': 'application/json' } @@ -1354,20 +1341,26 @@ const api = createApi({ } } - const response = await fetch(url, init) - + return undefined // Proceed with fetch + }, + onResponse: async ({ request, response, schemaPath }) => { // Cache successful GET responses - if (response.ok && (!init?.method || init.method === 'GET')) { + if (request.method === 'GET' && response.ok) { + const cacheKey = `${schemaPath}:${request.url}` const clone = response.clone() - const data = await clone.json() - cache.set(cacheKey, { - data, - timestamp: Date.now() - }) + try { + const data = await clone.json() + cache.set(cacheKey, { + data, + timestamp: Date.now() + }) + } catch (error) { + // Not JSON, skip caching + } } - return response + return undefined // No modification } }) @@ -1377,11 +1370,13 @@ export function clearCache() { } ``` -### **Rate Limiting** +#### Rate Limiting Middleware ```ts import { createApi } from '@devup-api/fetch' +const api = createApi({ baseUrl: 'https://api.example.com' }) + class RateLimiter { private queue: Array<() => void> = [] private requestsInWindow = 0 @@ -1435,11 +1430,10 @@ class RateLimiter { // 10 requests per second const rateLimiter = new RateLimiter(10, 1000) -const api = createApi({ - baseUrl: 'https://api.example.com', - fetch: async (url, init) => { +api.use({ + onRequest: async () => { await rateLimiter.throttle() - return fetch(url, init) + return undefined // No modification } }) ``` From 97970e6177bcb8c332d1b92385a6b8a7d58f2ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 09:56:11 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20Fix=20option=20name=20openapiFile?= =?UTF-8?q?=20=E2=86=92=20openapiFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed all occurrences of the incorrect option name: - openapiFile (❌ singular) → openapiFiles (✅ plural) - Updated type to string | string[] to support multiple OpenAPI schemas - Fixed Quick Start examples (Vite, Webpack, Rsbuild) - Fixed Configuration Options section with correct type signature The actual option name in the codebase is openapiFiles (plural), as defined in: - packages/core/src/options.ts - packages/utils/src/read-openapi.ts (normalizeOpenapiFiles function) --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 94e89da..760388c 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ export default defineConfig({ plugins: [ devupApi({ // Optional: customize configuration - openapiFile: 'openapi.json', // default - tempDir: 'df', // default - convertCase: 'camel', // default + openapiFiles: 'openapi.json', // default + tempDir: 'df', // default + convertCase: 'camel', // default }), ], }) @@ -152,7 +152,7 @@ const { devupApiWebpackPlugin } = require('@devup-api/webpack-plugin') module.exports = { plugins: [ new devupApiWebpackPlugin({ - openapiFile: 'openapi.json', + openapiFiles: 'openapi.json', tempDir: 'df', }), ], @@ -170,7 +170,7 @@ import { devupApiRsbuildPlugin } from '@devup-api/rsbuild-plugin' export default defineConfig({ plugins: [ devupApiRsbuildPlugin({ - openapiFile: 'openapi.json', + openapiFiles: 'openapi.json', tempDir: 'df', }), ], @@ -190,7 +190,7 @@ your-project/ └── vite.config.ts (or next.config.ts, etc.) ``` -> **Tip:** You can specify a custom path using the `openapiFile` option in plugin configuration. +> **Tip:** You can specify a custom path using the `openapiFiles` option in plugin configuration. ### **Step 4: Configure TypeScript** @@ -1470,10 +1470,11 @@ All plugins accept the following options: ```ts interface DevupApiOptions { /** - * OpenAPI file path + * OpenAPI file path(s) + * Can be a single file path or an array of file paths for multiple API schemas * @default 'openapi.json' */ - openapiFile?: string + openapiFiles?: string | string[] /** * Temporary directory for storing generated files @@ -1488,13 +1489,13 @@ interface DevupApiOptions { convertCase?: 'snake' | 'camel' | 'pascal' | 'maintain' /** - * Whether to make all properties non-nullable by default + * Whether to make all request properties non-nullable by default * @default false */ requestDefaultNonNullable?: boolean /** - * Whether to make all request properties non-nullable by default + * Whether to make all response properties non-nullable by default * @default true */ responseDefaultNonNullable?: boolean From 312828433bd20b1aedcce37f489a2bf2248fd080 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 12:52:51 +0000 Subject: [PATCH 4/4] docs: Remove unverified Timeout Middleware example Removed the Timeout Middleware example that was not found in the codebase: - The pattern was unverified and potentially had memory leaks (no timeout cleanup) - Used unnecessary middleware complexity Replaced with correct approach using signal option directly: - DevupApiRequestInit extends RequestInit, so signal is natively supported - Added two examples: wrapper function and direct signal usage - Includes proper timeout cleanup with clearTimeout() - Simpler, clearer, and verified approach This matches how React Query integration uses signal (see packages/react-query/src/query-client.ts:88-94) --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 760388c..16c3534 100644 --- a/README.md +++ b/README.md @@ -576,35 +576,67 @@ api.use({ }) ``` -#### Timeout Middleware +#### Request Timeout + +devup-api supports `signal` option from `RequestInit`, allowing you to implement timeouts easily: ```ts import { createApi } from '@devup-api/fetch' const api = createApi({ baseUrl: 'https://api.example.com' }) -api.use({ - onRequest: async ({ request }) => { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) // 5 second timeout - - // Create new request with abort signal - const modifiedRequest = new Request(request, { signal: controller.signal }) - - // Store timeout ID for cleanup - ;(modifiedRequest as any).__timeoutId = timeout +// Simple timeout wrapper +async function getWithTimeout( + api: ReturnType, + path: string, + options: any = {}, + timeoutMs = 5000 +) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) - return modifiedRequest + try { + const result = await api.get(path, { + ...options, + signal: controller.signal + }) + clearTimeout(timeout) + return result + } catch (error) { + clearTimeout(timeout) + throw error } -}) +} // Usage try { - const result = await api.get('getUsers', {}) + const result = await getWithTimeout(api, 'getUsers', {}, 5000) + if (result.data) { + console.log(result.data) + } +} catch (error) { + if (error.name === 'AbortError') { + console.error('Request timed out') + } else { + console.error('Request failed:', error) + } +} + +// Or use signal directly +const controller = new AbortController() +const timeout = setTimeout(() => controller.abort(), 5000) + +try { + const result = await api.get('getUsers', { + signal: controller.signal + }) + clearTimeout(timeout) + if (result.data) { console.log(result.data) } } catch (error) { + clearTimeout(timeout) if (error.name === 'AbortError') { console.error('Request timed out') }