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
- Next.js Server Actions
- 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
- Generate top-level Server Action functions from OpenAPI operationIds
- Import actions from
@devup-api/fetch/serverinstead of reaching intodf - Cold typing works before generated files exist, then becomes fully typed after the plugin runs
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-pluginNext.js
npm install @devup-api/fetch @devup-api/next-pluginWebpack
npm install @devup-api/fetch @devup-api/webpack-pluginRsbuild
npm install @devup-api/fetch @devup-api/rsbuild-pluginAdd the devup-api plugin to your build configuration:
Vite - vite.config.ts
import{defineConfig}from'vite'importdevupApifrom'@devup-api/vite-plugin'exportdefaultdefineConfig({plugins: [devupApi({// Optional: customize configurationopenapiFiles: 'openapi.json',// defaulttempDir: 'df',// defaultconvertCase: 'camel',// default}),],})Next.js - next.config.ts
importdevupApifrom'@devup-api/next-plugin'exportdefaultdevupApi({reactStrictMode: true,// devup-api plugin options can be passed here})Webpack - webpack.config.js
const{ devupApiWebpackPlugin }=require('@devup-api/webpack-plugin')module.exports={plugins: [newdevupApiWebpackPlugin({openapiFiles: 'openapi.json',tempDir: 'df',}),],}Rsbuild - rsbuild.config.ts
import{defineConfig}from'@rsbuild/core'import{devupApiRsbuildPlugin}from'@devup-api/rsbuild-plugin'exportdefaultdefineConfig({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 devThis 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 clientconstapi=createApi('https://api.example.com')// ✅ GET request using operationIdconstusers=awaitapi.get('getUsers',{query: {page: 1,limit: 20}})// ✅ GET request using path with paramsconstuser=awaitapi.get('/users/{id}',{params: {id: '123'},headers: {Authorization: 'Bearer YOUR_TOKEN'}})// ✅ POST request with typed bodyconstnewUser=awaitapi.post('createUser',{body: {name: 'John Doe',email: 'john@example.com'}})// ✅ Handle responseif(newUser.data){console.log('User created:',newUser.data.id)}elseif(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 yetconstapi=createApi('https://api.example.com')constresult=awaitapi.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 generatedconstapi=createApi('https://api.example.com')constresult=awaitapi.get('getUsers',{})// ✅ Fully typed: result.data is typed based on your OpenAPI schema// ❌ Type error if you use wrong parameters or pathsThis 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 operationIdconstusers=awaitapi.get('getUsers',{query: {page: 1,limit: 20}})// Using pathconstusers=awaitapi.get('/users',{query: {page: 1,limit: 20}})constnewPost=awaitapi.post('createPost',{body: {title: 'Hello World',content: 'This is a typed API request.'}})// Update entire resourceconstupdatedUser=awaitapi.put('/users/{id}',{params: {id: '123'},body: {name: 'Jane Doe',email: 'jane@example.com'}})// Partial updateconstpatchedUser=awaitapi.patch('/users/{id}',{params: {id: '123'},body: {name: 'Jane Doe'// Only update name}})constresult=awaitapi.delete('/users/{id}',{params: {id: '123'}})if(result.data){console.log('User deleted successfully')}// Single path parameterconstpost=awaitapi.get('/posts/{id}',{params: {id: '777'}})// Multiple path parametersconstcomment=awaitapi.get('/posts/{postId}/comments/{commentId}',{params: {postId: '123',commentId: '456'}})// Simple query paramsconstusers=awaitapi.get('getUsers',{query: {page: 1,limit: 20,sort: 'name',order: 'asc'}})// Query params with arraysconstproducts=awaitapi.get('getProducts',{query: {categories: ['electronics','books'],tags: ['sale','new']}})// Custom headersconstuser=awaitapi.get('/users/{id}',{params: {id: '123'},headers: {'Authorization': 'Bearer YOUR_TOKEN','X-Custom-Header': 'custom-value','Accept-Language': 'en-US'}})constresult=awaitapi.get('getUser',{params: {id: '123'}})if(result.data){// Success response - fully typed!console.log(result.data.name)console.log(result.data.email)}elseif(result.error){// Error response - also typed based on OpenAPI error schemasconsole.error('Error:',result.error.message)console.error('Status:',result.error.status)}// Basic error handlingconstresult=awaitapi.post('createUser',{body: {name: 'John',email: 'john@example.com'}})if(result.error){switch(result.error.status){case400:
console.error('Bad request:',result.error.message)breakcase401:
console.error('Unauthorized')// Redirect to loginbreakcase403:
console.error('Forbidden')breakcase404:
console.error('Not found')breakcase500:
console.error('Server error')breakdefault:
console.error('Unknown error:',result.error)}}// Try-catch for network errorstry{constresult=awaitapi.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,typeDevupObject}from'@devup-api/fetch'// Access response types from the default OpenAPI schematypeUser=DevupObject['User']typeProduct=DevupObject['Product']// Use in your codeconstuser: User={id: '123',name: 'John Doe',email: 'john@example.com'}// For request/error types, specify the type categorytypeCreateUserRequest=DevupObject<'request'>['CreateUserBody']typeApiError=DevupObject<'error'>['ErrorResponse']// Use types in function parametersfunctiondisplayUser(user: User){console.log(`${user.name} (${user.email})`)}// Use types in React componentsinterfaceUserCardProps{user: UseronUpdate: (data: CreateUserRequest)=>void}functionUserCard({ user, onUpdate }: UserCardProps){// Component implementation}Middleware allows you to intercept and modify requests and responses globally.
import{createApi}from'@devup-api/fetch'constapi=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)returnundefined// No modification},onResponse: async({ response, schemaPath })=>{console.log(`✅ Response: ${response.status}${schemaPath}`)returnundefined// No modification}})devup-api supports signal option from RequestInit, allowing you to implement timeouts easily:
import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})// Simple timeout wrapperasyncfunctiongetWithTimeout<T>(api: ReturnType<typeofcreateApi>,path: string,options: any={},timeoutMs=5000){constcontroller=newAbortController()consttimeout=setTimeout(()=>controller.abort(),timeoutMs)try{constresult=awaitapi.get(path,{
...options,signal: controller.signal})clearTimeout(timeout)returnresult}catch(error){clearTimeout(timeout)throwerror}}// Usagetry{constresult=awaitgetWithTimeout(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 directlyconstcontroller=newAbortController()consttimeout=setTimeout(()=>controller.abort(),5000)try{constresult=awaitapi.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'constapi=createApi({baseUrl: 'https://api.example.com'})api.use({onResponse: async({ request, response })=>{constmaxRetries=3constretryDelay=1000// 1 second// Retry on server errors (5xx)if(response.status>=500&&response.status<600){for(leti=0;i<maxRetries;i++){awaitnewPromise(resolve=>setTimeout(resolve,retryDelay*Math.pow(2,i)))constretryResponse=awaitfetch(request)if(retryResponse.ok){returnretryResponse}// Last retry failedif(i===maxRetries-1){returnretryResponse}}}returnundefined// 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,typeDevupObject}from'@devup-api/fetch'// Default server (uses openapi.json)constapi=createApi({baseUrl: 'https://api.example.com',})// Second server (uses openapi2.json)constapi2=createApi({baseUrl: 'https://api.another-service.com',serverName: 'openapi2.json',})// Make requests to different serversconstusers=awaitapi.get('getUsers',{})constproducts=awaitapi2.get('getProducts',{})// Access types from different schemastypeUser=DevupObject['User']// From openapi.jsontypeProduct=DevupObject<'response','openapi2.json'>['Product']// From openapi2.jsondevup-api generates named Server Action wrappers for operationId-based API calls by default. This is useful in Next.js App Router projects when you want to call server-side API functions from Client Components without manually writing one action per endpoint.
Set serverActions.baseUrl when generated actions should call a specific API origin:
// next.config.tsimportdevupApifrom'@devup-api/next-plugin'exportdefaultdevupApi({reactStrictMode: true,serverActions: {baseUrl: 'https://api.example.com',},})Then import generated actions from the virtual server module:
'use client'import{getUser}from'@devup-api/fetch/server'exportfunctionUserButton(){return(<buttontype="button"onClick={async()=>{constresult=awaitgetUser({params: {id: '123'}})console.log(result.data)console.log(result.response.status)}}>
Load user
</button>)}The generated df/server.ts file contains 'use server' and exports one named async function for every operationId in your OpenAPI schemas. You should import from @devup-api/fetch/server, not from df/server.ts directly; the build plugin aliases that module to the generated file.
Generated actions return DevupApiResponse<T, E, SerializedResponse>. This keeps the same data / error / isOk / isError shape as normal api.get() calls, while replacing the native Response instance with a plain serializable response object that can cross the Server Action boundary.
During cold typing, @devup-api/fetch/server is still importable before df exists. The fallback keeps initial setup from failing, and the generated module replaces it with strict operation-specific types after dev or build runs.
Server Actions are enabled by default. Disable generation explicitly with serverActions: false or serverActions: { enabled: false }.
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-queryimport{QueryClient,QueryClientProvider}from'@tanstack/react-query'import{createApi}from'@devup-api/fetch'import{createQueryClient}from'@devup-api/react-query'// Create API clientconstapi=createApi('https://api.example.com')// Create React Query clientconstqueryClient=createQueryClient(api)// Create TanStack QueryClientconsttanstackQueryClient=newQueryClient()// Wrap your appfunctionApp(){return(<QueryClientProviderclient={tanstackQueryClient}><YourApp/></QueryClientProvider>)}import{queryClient}from'./api'functionUserProfile({ 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><buttononClick={()=>refetch()}>Refresh</button></div>)}functionUserList(){const{ data, isLoading }=queryClient.useQuery('get','getUsers',{query: {page: 1,limit: 10}},{// React Query optionsstaleTime: 5*60*1000,// 5 minutesgcTime: 10*60*1000,// 10 minutesrefetchOnWindowFocus: false,retry: 3,})if(isLoading)return<div>Loading...</div>return(<ul>{data?.map(user=>(<likey={user.id}>{user.name}</li>))}</ul>)}functionCreateUserForm(){constmutation=queryClient.useMutation('post','createUser',{onSuccess: (data)=>{console.log('User created:',data)// Invalidate and refetchtanstackQueryClient.invalidateQueries({queryKey: ['getUsers']})},onError: (error)=>{console.error('Failed to create user:',error)}})consthandleSubmit=(e: React.FormEvent<HTMLFormElement>)=>{e.preventDefault()constformData=newFormData(e.currentTarget)mutation.mutate({body: {name: formData.get('name')asstring,email: formData.get('email')asstring,}})}return(<formonSubmit={handleSubmit}><inputname="name"placeholder="Name"required/><inputname="email"type="email"placeholder="Email"required/><buttontype="submit"disabled={mutation.isPending}>{mutation.isPending ? 'Creating...' : 'Create User'}</button>{mutation.isError&&<div>Error: {mutation.error.message}</div>}{mutation.isSuccess&&<div>User created successfully!</div>}</form>)}functionUpdateUserForm({ userId }: {userId: string}){constmutation=queryClient.useMutation('patch','/users/{id}',{onMutate: async(variables)=>{// Cancel outgoing refetchesawaittanstackQueryClient.cancelQueries({queryKey: ['getUser',userId]})// Snapshot the previous valueconstpreviousUser=tanstackQueryClient.getQueryData(['getUser',userId])// Optimistically update to the new valueif(previousUser){tanstackQueryClient.setQueryData(['getUser',userId],{
...previousUser,
...variables.body,})}return{ previousUser }},onError: (err,variables,context)=>{// Rollback on errorif(context?.previousUser){tanstackQueryClient.setQueryData(['getUser',userId],context.previousUser)}},onSettled: ()=>{// Refetch after error or successtanstackQueryClient.invalidateQueries({queryKey: ['getUser',userId]})},})return(<buttononClick={()=>mutation.mutate({params: {id: userId},body: {name: 'Updated Name'}})}>
Update User
</button>)}import{Suspense}from'react'functionUserList(){// No loading state needed - Suspense handles itconst{ data }=queryClient.useSuspenseQuery('get','getUsers',{})return(<ul>{data.map(user=>(<likey={user.id}>{user.name}</li>))}</ul>)}functionApp(){return(<Suspensefallback={<div>Loading users...</div>}><UserList/></Suspense>)}functionInfiniteUserList(){const{
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,}=queryClient.useInfiniteQuery('get','getUsers',{initialPageParam: 1,getNextPageParam: (lastPage,allPages)=>{// Return next page number or undefined if no more pagesreturnlastPage.hasMore ? allPages.length+1 : undefined},})if(isLoading)return<div>Loading...</div>return(<div>{data?.pages.map((page,i)=>(<divkey={i}>{page.users.map(user=>(<divkey={user.id}><h3>{user.name}</h3><p>{user.email}</p></div>))}</div>))}{hasNextPage&&(<buttononClick={()=>fetchNextPage()}disabled={isFetchingNextPage}>{isFetchingNextPage ? 'Loading more...' : 'Load More'}</button>)}</div>)}import{useEffect,useRef}from'react'functionInfiniteScrollList(){constobserverTarget=useRef<HTMLDivElement>(null)const{ data, fetchNextPage, hasNextPage, isFetchingNextPage }=queryClient.useInfiniteQuery('get','getUsers',{initialPageParam: 1,getNextPageParam: (lastPage)=>lastPage.nextPage,})useEffect(()=>{constobserver=newIntersectionObserver((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)=>(<divkey={i}>{page.users.map(user=>(<divkey={user.id}>{user.name}</div>))}</div>))}<divref={observerTarget}style={{height: '20px'}}>{isFetchingNextPage&&'Loading more...'}</div></div>)}functionUserPosts({ userId }: {userId: string}){// First, fetch the userconst{data: user}=queryClient.useQuery('get','/users/{id}',{params: {id: userId}})// Then fetch posts, but only if user is loadedconst{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=>(<articlekey={post.id}><h3>{post.title}</h3><p>{post.content}</p></article>))}</div>)}import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})// Add authentication middlewareapi.use({onRequest: async({ request })=>{consttoken=localStorage.getItem('accessToken')if(token){constheaders=newHeaders(request.headers)headers.set('Authorization',`Bearer ${token}`)returnnewRequest(request,{ headers })}returnundefined// No modification}})import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})letaccessToken=localStorage.getItem('accessToken')letrefreshToken=localStorage.getItem('refreshToken')asyncfunctionrefreshAccessToken(): Promise<string>{constresponse=awaitfetch('https://api.example.com/auth/refresh',{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ refreshToken })})if(!response.ok){window.location.href='/login'thrownewError('Failed to refresh token')}constdata=awaitresponse.json()accessToken=data.accessTokenrefreshToken=data.refreshTokenlocalStorage.setItem('accessToken',accessToken)localStorage.setItem('refreshToken',refreshToken)returnaccessToken}// Add authentication and token refresh middlewareapi.use({onRequest: async({ request })=>{// Add current access tokenconstheaders=newHeaders(request.headers)if(accessToken){headers.set('Authorization',`Bearer ${accessToken}`)}returnnewRequest(request,{ headers })},onResponse: async({ request, response })=>{// If unauthorized, try to refresh token and retryif(response.status===401){try{constnewToken=awaitrefreshAccessToken()// Retry the original request with new tokenconstheaders=newHeaders(request.headers)headers.set('Authorization',`Bearer ${newToken}`)constretryResponse=awaitfetch(newRequest(request,{ headers }))returnretryResponse}catch(error){// Refresh failed, redirect to loginwindow.location.href='/login'throwerror}}returnundefined// No modification}})// Assuming your OpenAPI schema has a file upload endpointasyncfunctionuploadFile(file: File){constformData=newFormData()formData.append('file',file)constresult=awaitapi.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)}}// UsageconsthandleFileChange=(e: React.ChangeEvent<HTMLInputElement>)=>{constfile=e.target.files?.[0]if(file){uploadFile(file)}}import{createApi}from'@devup-api/fetch'functionuploadFilesWithProgress(files: File[],onProgress: (progress: number)=>void){returnnewPromise((resolve,reject)=>{constxhr=newXMLHttpRequest()constformData=newFormData()files.forEach((file,index)=>{formData.append(`file${index}`,file)})xhr.upload.addEventListener('progress',(e)=>{if(e.lengthComputable){constprogress=(e.loaded/e.total)*100onProgress(progress)}})xhr.addEventListener('load',()=>{if(xhr.status>=200&&xhr.status<300){resolve(JSON.parse(xhr.responseText))}else{reject(newError(`Upload failed with status ${xhr.status}`))}})xhr.addEventListener('error',()=>reject(newError('Upload failed')))xhr.open('POST','https://api.example.com/upload/multiple')xhr.setRequestHeader('Authorization',`Bearer ${getToken()}`)xhr.send(formData)})}// Usage in ReactfunctionFileUploader(){const[progress,setProgress]=useState(0)consthandleUpload=async(files: FileList)=>{try{constresult=awaituploadFilesWithProgress(Array.from(files),setProgress)console.log('Upload complete:',result)}catch(error){console.error('Upload failed:',error)}}return(<div><inputtype="file"multipleonChange={(e)=>e.target.files&&handleUpload(e.target.files)}/><progressvalue={progress}max={100}/></div>)}import{createApi}from'@devup-api/fetch'constapi=createApi('https://api.example.com')functionSearchComponent(){const[controller,setController]=useState<AbortController|null>(null)consthandleSearch=async(query: string)=>{// Cancel previous requestif(controller){controller.abort()}// Create new controllerconstnewController=newAbortController()setController(newController)try{// Use devup-api with abort signalconstresult=awaitapi.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 unmountif(controller){controller.abort()}}},[controller])return(<inputtype="text"onChange={(e)=>handleSearch(e.target.value)}placeholder="Search..."/>)}import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})api.use({onRequest: async({ request, schemaPath, params, query, body })=>{conststartTime=performance.now();(requestasany).__startTime=startTimeconsole.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()returnundefined// No modification},onResponse: async({ request, response, schemaPath })=>{conststartTime=(requestasany).__startTime||0constduration=(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)`)}returnundefined// No modification}})import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})// Simple in-memory cacheconstcache=newMap<string,{data: any;timestamp: number}>()constCACHE_TTL=5*60*1000// 5 minutesapi.use({onRequest: async({ request, schemaPath })=>{// Only cache GET requestsif(request.method==='GET'){constcacheKey=`${schemaPath}:${request.url}`constcached=cache.get(cacheKey)if(cached&&Date.now()-cached.timestamp<CACHE_TTL){console.log('Cache hit:',cacheKey)// Return cached response directly (skip fetch)returnnewResponse(JSON.stringify(cached.data),{status: 200,headers: {'Content-Type': 'application/json'}})}}returnundefined// Proceed with fetch},onResponse: async({ request, response, schemaPath })=>{// Cache successful GET responsesif(request.method==='GET'&&response.ok){constcacheKey=`${schemaPath}:${request.url}`constclone=response.clone()try{constdata=awaitclone.json()cache.set(cacheKey,{
data,timestamp: Date.now()})}catch(error){// Not JSON, skip caching}}returnundefined// No modification}})// Clear cache functionexportfunctionclearCache(){cache.clear()}import{createApi}from'@devup-api/fetch'constapi=createApi({baseUrl: 'https://api.example.com'})classRateLimiter{privatequeue: Array<()=>void>=[]privaterequestsInWindow=0privatewindowStart=Date.now()constructor(privatemaxRequests: number,privatewindowMs: number){}asyncthrottle(): Promise<void>{returnnewPromise((resolve)=>{constnow=Date.now()// Reset window if expiredif(now-this.windowStart>=this.windowMs){this.requestsInWindow=0this.windowStart=now}// If under limit, proceed immediatelyif(this.requestsInWindow<this.maxRequests){this.requestsInWindow++resolve()}else{// Queue the requestthis.queue.push(()=>{this.requestsInWindow++resolve()})// Schedule queue processingconstdelay=this.windowMs-(now-this.windowStart)setTimeout(()=>{this.requestsInWindow=0this.windowStart=Date.now()this.processQueue()},delay)}})}privateprocessQueue(){while(this.queue.length>0&&this.requestsInWindow<this.maxRequests){constnext=this.queue.shift()next?.()}}}// 10 requests per secondconstrateLimiter=newRateLimiter(10,1000)api.use({onRequest: async()=>{awaitrateLimiter.throttle()returnundefined// No modification}})import{createApi}from'@devup-api/fetch'constgetBaseUrl=()=>{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'}}constapi=createApi(getBaseUrl())// Or with environment variablesconstapi=createApi(process.env.VITE_API_BASE_URL||'http://localhost:3000')All plugins accept the following options:
interfaceDevupApiOptions{/** * 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/** * Generate operationId-based Server Action wrappers and expose them via * @devup-api/fetch/server. * @default true */serverActions?: boolean|{enabled?: booleanbaseUrl?: string}}- 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
- Generates named Server Actions in
df/server.tsby default - 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:fixThis 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