diff --git a/README.md b/README.md index af185a0..16c3534 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 + openapiFiles: '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({ + openapiFiles: '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({ + openapiFiles: '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 `openapiFiles` 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,147 @@ 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 +} +``` + +### **Middleware Examples** + +Middleware allows you to intercept and modify requests and responses globally. + +#### Request Logging Middleware + +```ts +import { createApi } from '@devup-api/fetch' + +const api = createApi({ baseUrl: 'https://api.example.com' }) + +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 + } +}) +``` + +#### 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' }) + +// Simple timeout wrapper +async function getWithTimeout( + api: ReturnType, + path: string, + options: any = {}, + timeoutMs = 5000 +) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + 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 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') + } +} +``` + +#### Retry Logic Middleware + +```ts +import { createApi } from '@devup-api/fetch' + +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 + } + } + } + + return undefined // No modification + } +}) ``` --- @@ -339,7 +716,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 +726,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,59 +764,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 with Middleware + +```ts +import { createApi } from '@devup-api/fetch' + +const api = createApi({ baseUrl: 'https://api.example.com' }) + +// Add authentication middleware +api.use({ + onRequest: async ({ request }) => { + const token = localStorage.getItem('accessToken') + + if (token) { + const headers = new Headers(request.headers) + headers.set('Authorization', `Bearer ${token}`) + + return new Request(request, { headers }) + } + + return undefined // No modification + } +}) +``` + +#### 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') + +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) { + 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 +} + +// 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}`) + } + 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}`) + + const retryResponse = await fetch(new Request(request, { headers })) + return retryResponse + } catch (error) { + // Refresh failed, redirect to login + window.location.href = '/login' + throw error + } + } + + return undefined // No modification + } +}) +``` + +### **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' + +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 { + // Use devup-api with abort signal + const result = await api.get('searchUsers', { + query: { q: query }, + signal: newController.signal + }) + + if (result.data) { + console.log('Search results:', result.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 Middleware + +```ts +import { createApi } from '@devup-api/fetch' + +const api = createApi({ baseUrl: 'https://api.example.com' }) + +api.use({ + onRequest: async ({ request, schemaPath, params, query, body }) => { + const startTime = performance.now() + ;(request as any).__startTime = startTime + + 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() + + 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 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 + +api.use({ + onRequest: async ({ request, schemaPath }) => { + // Only cache GET requests + 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 directly (skip fetch) + return new Response(JSON.stringify(cached.data), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + } + + return undefined // Proceed with fetch + }, + onResponse: async ({ request, response, schemaPath }) => { + // Cache successful GET responses + if (request.method === 'GET' && response.ok) { + const cacheKey = `${schemaPath}:${request.url}` + const clone = response.clone() + + try { + const data = await clone.json() + cache.set(cacheKey, { + data, + timestamp: Date.now() + }) + } catch (error) { + // Not JSON, skip caching + } + } + + return undefined // No modification + } +}) + +// Clear cache function +export function clearCache() { + cache.clear() +} +``` + +#### 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 + 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) + +api.use({ + onRequest: async () => { + await rateLimiter.throttle() + return undefined // No modification + } +}) +``` + +### **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 @@ -431,10 +1502,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 @@ -449,13 +1521,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 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.