Skip to content

Repository files navigation

devup-api

npm versionnpm downloadsnpm bundle sizeLicenseGitHub ActionsCodecovGitHub starsGitHub forksGitHub issuesGitHub pull requestsGitHub last commitTypeScriptBunBiomeNode.jsOpenAPI

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.

📖 Table of Contents


✨ Features

🔍 OpenAPI-driven type generation

  • Reads openapi.json and 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.

🪝 Fetch-compatible design

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

🔌 Build tool integration

  • Works seamlessly with Vite, Next.js, Webpack, and Rsbuild
  • Automatic type generation during build time
  • Zero runtime overhead

⚡ Generated Next.js Server Actions

  • Generate top-level Server Action functions from OpenAPI operationIds
  • Import actions from @devup-api/fetch/server instead of reaching into df
  • Cold typing works before generated files exist, then becomes fully typed after the plugin runs

🚀 Quick Start

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
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

Step 2: Configure Your Build Tool

Add 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',}),],})

Step 3: Add Your OpenAPI Schema

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 openapiFiles option in plugin configuration.

Step 4: Configure TypeScript

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:df is the default temp directory. If you customized tempDir, use that path instead (e.g., "your-temp-dir/**/*.d.ts").

Step 5: Run Your Build

Start your development server to generate types:

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!

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.


🔥 Cold Typing vs Boild Typing

devup-api uses a two-phase typing system to ensure smooth development experience:

Cold Typing ❄️

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.ts file 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 🔥

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 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 paths

Why This Matters

This two-phase approach ensures:

  1. No blocking: You can start coding immediately without waiting for the build
  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

📦 Packages

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

📚 API Usage

Basic Requests

GET Request

// Using operationIdconstusers=awaitapi.get('getUsers',{query: {page: 1,limit: 20}})// Using pathconstusers=awaitapi.get('/users',{query: {page: 1,limit: 20}})

POST Request

constnewPost=awaitapi.post('createPost',{body: {title: 'Hello World',content: 'This is a typed API request.'}})

PUT/PATCH 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}})

DELETE Request

constresult=awaitapi.delete('/users/{id}',{params: {id: '123'}})if(result.data){console.log('User deleted successfully')}

Path Parameters

// Single path parameterconstpost=awaitapi.get('/posts/{id}',{params: {id: '777'}})// Multiple path parametersconstcomment=awaitapi.get('/posts/{postId}/comments/{commentId}',{params: {postId: '123',commentId: '456'}})

Query Parameters

// 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']}})

Headers

// Custom headersconstuser=awaitapi.get('/users/{id}',{params: {id: '123'},headers: {'Authorization': 'Bearer YOUR_TOKEN','X-Custom-Header': 'custom-value','Accept-Language': 'en-US'}})

Response Handling

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)}

Error Handling

// 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)}

Using DevupObject for Type References

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 Examples

Middleware allows you to intercept and modify requests and responses globally.

Request Logging Middleware

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}})

Request Timeout

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')}}

Retry Logic Middleware

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}})

🌐 Multiple API Servers

devup-api supports multiple OpenAPI schemas for working with different API servers.

Configuration

Place multiple OpenAPI files in your project (e.g., openapi.json, openapi2.json) and the plugin will generate types for each.

Usage

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.json

⚡ Next.js Server Actions

devup-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 }.


🔄 React Query Integration

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

npm install @devup-api/react-query @tanstack/react-query

Setup

import{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>)}

useQuery - Fetching Data

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>)}

useQuery with Query Options

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>)}

useMutation - Creating/Updating Data

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>)}

useMutation with Optimistic Updates

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>)}

useSuspenseQuery - With React Suspense

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>)}

useInfiniteQuery - Pagination

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>)}

useInfiniteQuery - Infinite Scroll

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>)}

Dependent Queries

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>)}

🚀 Advanced Usage

Authentication & Authorization

JWT Authentication with Middleware

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}})

Token Refresh Flow with Middleware

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}})

File Upload

Single File Upload

// 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)}}

Multiple File Upload with Progress

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>)}

Request Cancellation

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..."/>)}

Logging & Debugging Middleware

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}})

Caching Middleware

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()}

Rate Limiting Middleware

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}})

Custom Base URL per Environment

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')

⚙️ Configuration Options

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}}

🎯 How It Works

  1. Plugin reads your openapi.json during build time
  2. Extracts paths, methods, schemas, parameters, and request bodies
  3. Generates TypeScript interface definitions automatically
  4. Creates a URL map for operationId-based API calls
  5. Generates named Server Actions in df/server.ts by default
  6. Builds a typed wrapper around fetch() with full type safety

🛠️ Development

# 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

🙏 Acknowledgments

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.


📄 License

Apache 2.0

About

A fully typed API client generator powered by OpenAPI

Resources

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages