npm version npm downloads npm bundle size License GitHub Actions Codecov GitHub stars GitHub forks GitHub issues GitHub pull requests GitHub last commit TypeScript Bun Biome Node.js OpenAPI
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. Just write API calls β the types are already there.
- Features
- Quick Start
- Cold Typing vs Boild Typing
- Packages
- API Usage
- Multiple API Servers
- React Query Integration
- Advanced Usage
- Configuration Options
- How It Works
- Development
- Acknowledgments
- License
- Reads
openapi.jsonand transforms every path, method, schema into typed API functions. - Parameters, request bodies, headers, responses β all typed automatically.
- No need to write or maintain separate TypeScript definitions.
devup-api feels like using fetch, but with superpowers:
- Path params automatically replaced
- Query/body/header types enforced
- Typed success & error responses
- Optional runtime schema validation
- Minimal abstraction over standard fetch
- Works seamlessly with Vite, Next.js, Webpack, and Rsbuild
- Automatic type generation during build time
- Zero runtime overhead
Get started with devup-api in under 5 minutes! Follow these steps to generate fully typed API clients from your OpenAPI schema.
Choose the plugin for your build tool and install it along with the core fetch package:
Vite
npm install @devup-api/fetch @devup-api/vite-plugin
Next.js
npm install @devup-api/fetch @devup-api/next-plugin
Webpack
npm install @devup-api/fetch @devup-api/webpack-plugin
Rsbuild
npm install @devup-api/fetch @devup-api/rsbuild-plugin
Add the devup-api plugin to your build configuration:
Vite - vite.config.ts
import { defineConfig } from 'vite' import devupApi from '@devup-api/vite-plugin' export default defineConfig({ plugins: [ devupApi({ // Optional: customize configuration openapiFiles: 'openapi.json', // default tempDir: 'df', // default convertCase: 'camel', // default }), ], })
Next.js - next.config.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
const { devupApiWebpackPlugin } = require('@devup-api/webpack-plugin') module.exports = { plugins: [ new devupApiWebpackPlugin({ openapiFiles: 'openapi.json', tempDir: 'df', }), ], }
Rsbuild - rsbuild.config.ts
import { defineConfig } from '@rsbuild/core' import { devupApiRsbuildPlugin } from '@devup-api/rsbuild-plugin' export default defineConfig({ plugins: [ devupApiRsbuildPlugin({ openapiFiles: 'openapi.json', tempDir: 'df', }), ], })
Place your openapi.json file in the project root:
your-project/
βββ openapi.json β Your OpenAPI schema
βββ src/
βββ package.json
βββ vite.config.ts (or next.config.ts, etc.)
Tip: You can specify a custom path using the
openapiFilesoption in plugin configuration.
Update your tsconfig.json to include the generated type definitions:
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler"
// ... other options
},
"include": [
"src",
"df/**/*.d.ts" // β Include generated types
]
}Note:
dfis the default temp directory. If you customizedtempDir, use that path instead (e.g.,"your-temp-dir/**/*.d.ts").
Start your development server to generate types:
npm run dev
This will:
- Read your
openapi.jsonfile - Generate TypeScript type definitions in
df/api.d.ts - Enable full type safety for your API calls (Boild Typing π₯)
Now you're ready to make fully typed API calls!
import { createApi } from '@devup-api/fetch' // Create API client const api = createApi('https://api.example.com') // β GET request using operationId const users = await api.get('getUsers', { query: { page: 1, limit: 20 } }) // β GET request using path with params const user = await api.get('/users/{id}', { params: { id: '123' }, headers: { Authorization: 'Bearer YOUR_TOKEN' } }) // β 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.
devup-api uses a two-phase typing system to ensure smooth development experience:
Cold typing refers to the state before the TypeScript interface files are generated. This happens when:
- You first install the plugin
- The build hasn't run yet
- The generated
api.d.tsfile doesn't exist
During cold typing:
- All API types are treated as
any - Type checking is relaxed to prevent type errors
- Your code will compile and run without issues
- You can write API calls without waiting for type generation
// Cold typing: No type errors even if api.d.ts doesn't exist yet const api = createApi('https://api.example.com') const result = await api.get('getUsers', {}) // β Works, types are 'any'
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 (
devorbuild) - The plugin has generated
api.d.tsin the temp directory - TypeScript can find and use the generated types
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!
// Boild typing: Full type safety after api.d.ts is generated const api = createApi('https://api.example.com') 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
This two-phase approach ensures:
- No blocking: You can start coding immediately without waiting for the build
- Gradual typing: Types become available as soon as the build runs
- Production safety: Full type checking in production builds
- Developer experience: No false type errors during initial setup
- Zero boilerplate: Once boiled, your types are ready - no manual type definitions needed
This is a monorepo containing multiple packages:
@devup-api/core- Core types and interfaces@devup-api/utils- Utility functions for OpenAPI processing@devup-api/generator- TypeScript interface generator from OpenAPI schemas@devup-api/fetch- Type-safe API client@devup-api/react-query- TanStack React Query integration@devup-api/vite-plugin- Vite plugin@devup-api/next-plugin- Next.js plugin@devup-api/webpack-plugin- Webpack plugin@devup-api/rsbuild-plugin- Rsbuild plugin
// Using operationId const users = await api.get('getUsers', { query: { page: 1, limit: 20 } }) // Using path const users = await api.get('/users', { query: { page: 1, limit: 20 } })
const newPost = await api.post('createPost', { body: { title: 'Hello World', content: 'This is a typed API request.' } })
// 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 } })
const result = await api.delete('/users/{id}', { params: { id: '123' } }) if (result.data) { console.log('User deleted successfully') }
// 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' } })
// 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'] } })
// 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' } })
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 - also typed based on OpenAPI error schemas console.error('Error:', result.error.message) console.error('Status:', result.error.status) }
// 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) }
DevupObject allows you to reference generated schema types directly, which is useful for typing variables, function parameters, or component props.
import { createApi, type DevupObject } from '@devup-api/fetch' // Access response types from the default OpenAPI schema type User = DevupObject['User'] type Product = DevupObject['Product'] // Use in your code const user: User = { id: '123', name: 'John Doe', email: 'john@example.com' } // 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 allows you to intercept and modify requests and responses globally.
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 } })
devup-api supports signal option from RequestInit, allowing you to implement timeouts easily:
import { createApi } from '@devup-api/fetch' const api = createApi({ baseUrl: 'https://api.example.com' }) // Simple timeout wrapper async function getWithTimeout<T>( api: ReturnType<typeof createApi>, 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') } }
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 } })
devup-api supports multiple OpenAPI schemas for working with different API servers.
Place multiple OpenAPI files in your project (e.g., openapi.json, openapi2.json) and the plugin will generate types for each.
import { createApi, type DevupObject } from '@devup-api/fetch' // Default server (uses openapi.json) const api = createApi({ baseUrl: 'https://api.example.com', }) // Second server (uses openapi2.json) const api2 = createApi({ baseUrl: 'https://api.another-service.com', serverName: 'openapi2.json', }) // Make requests to different servers const users = await api.get('getUsers', {}) const products = await api2.get('getProducts', {}) // Access types from different schemas type User = DevupObject['User'] // From openapi.json type Product = DevupObject<'response', 'openapi2.json'>['Product'] // From openapi2.json
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.
npm install @devup-api/react-query @tanstack/react-query
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 ( <QueryClientProvider client={tanstackQueryClient}> <YourApp /> </QueryClientProvider> ) }
import { queryClient } from './api' function UserProfile({ userId }: { userId: string }) { const { data, isLoading, error, refetch } = queryClient.useQuery( 'get', '/users/{id}', { params: { id: userId } } ) if (isLoading) return <div>Loading...</div> if (error) return <div>Error: {error.message}</div> return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> <button onClick={() => refetch()}>Refresh</button> </div> ) }
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 <div>Loading...</div> return ( <ul> {data?.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }
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<HTMLFormElement>) => { e.preventDefault() const formData = new FormData(e.currentTarget) mutation.mutate({ body: { name: formData.get('name') as string, email: formData.get('email') as string, } }) } return ( <form onSubmit={handleSubmit}> <input name="name" placeholder="Name" required /> <input name="email" type="email" placeholder="Email" required /> <button type="submit" disabled={mutation.isLoading}> {mutation.isLoading ? 'Creating...' : 'Create User'} </button> {mutation.isError && <div>Error: {mutation.error.message}</div>} {mutation.isSuccess && <div>User created successfully!</div>} </form> ) }
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 ( <button onClick={() => mutation.mutate({ params: { id: userId }, body: { name: 'Updated Name' } })}> Update User </button> ) }
import { Suspense } from 'react' function UserList() { // No loading state needed - Suspense handles it const { data } = queryClient.useSuspenseQuery('get', 'getUsers', {}) return ( <ul> {data.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) } function App() { return ( <Suspense fallback={<div>Loading users...</div>}> <UserList /> </Suspense> ) }
function InfiniteUserList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, } = queryClient.useInfiniteQuery( 'get', 'getUsers', { initialPageParam: 1, getNextPageParam: (lastPage, allPages) => { // Return next page number or undefined if no more pages return lastPage.hasMore ? allPages.length + 1 : undefined }, } ) if (isLoading) return <div>Loading...</div> return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.users.map(user => ( <div key={user.id}> <h3>{user.name}</h3> <p>{user.email}</p> </div> ))} </div> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading more...' : 'Load More'} </button> )} </div> ) }
import { useEffect, useRef } from 'react' function InfiniteScrollList() { const observerTarget = useRef<HTMLDivElement>(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 ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.users.map(user => ( <div key={user.id}>{user.name}</div> ))} </div> ))} <div ref={observerTarget} style={{ height: '20px' }}> {isFetchingNextPage && 'Loading more...'} </div> </div> ) }
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 ( <div> <h2>{user?.name}'s Posts</h2> {posts?.map(post => ( <article key={post.id}> <h3>{post.title}</h3> <p>{post.content}</p> </article> ))} </div> ) }
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 } })
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<string> { 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 } })
// 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<HTMLInputElement>) => { const file = e.target.files?.[0] if (file) { uploadFile(file) } }
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 ( <div> <input type="file" multiple onChange={(e) => e.target.files && handleUpload(e.target.files)} /> <progress value={progress} max={100} /> </div> ) }
import { createApi } from '@devup-api/fetch' const api = createApi('https://api.example.com') function SearchComponent() { const [controller, setController] = useState<AbortController | null>(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 ( <input type="text" onChange={(e) => handleSearch(e.target.value)} placeholder="Search..." /> ) }
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 } })
import { createApi } from '@devup-api/fetch' const api = createApi({ baseUrl: 'https://api.example.com' }) // Simple in-memory cache const cache = new Map<string, { data: any; timestamp: number }>() 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() }
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<void> { 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 } })
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')
All plugins accept the following options:
interface DevupApiOptions { /** * OpenAPI file path(s) * Can be a single file path or an array of file paths for multiple API schemas * @default 'openapi.json' */ openapiFiles?: string | string[] /** * Temporary directory for storing generated files * @default 'df' */ tempDir?: string /** * Case conversion type for API endpoint names and parameters * @default 'camel' */ convertCase?: 'snake' | 'camel' | 'pascal' | 'maintain' /** * Whether to make all request properties non-nullable by default * @default false */ requestDefaultNonNullable?: boolean /** * Whether to make all response properties non-nullable by default * @default true */ responseDefaultNonNullable?: boolean }
- Plugin reads your
openapi.jsonduring build time - Extracts paths, methods, schemas, parameters, and request bodies
- Generates TypeScript interface definitions automatically
- Creates a URL map for operationId-based API calls
- Builds a typed wrapper around
fetch()with full type safety
# Install dependencies bun install # Build all packages bun run build # Run tests bun test # Lint bun run lint # Fix linting issues bun run lint:fix
This project is inspired by openapi-fetch, a fantastic library for type-safe API clients. devup-api builds upon similar concepts while providing additional features like build-time type generation and seamless integration with modern build tools.
Apache 2.0