redux-toolkit icon indicating copy to clipboard operation
redux-toolkit copied to clipboard

How to share generated client from @rtk-query/codegen-openapi ?

Open smff opened this issue 10 months ago • 12 comments

I want to generate api and share it (npm lib for example) with different projects, but I faced a problem:

@rtk-query/codegen-openapi required an apiFile which includes createApi instance (with baseQuery, reducerPath and etc)

Is there any solution (from the "box" or tricky way) that would allow to just generate endpoints with @rtk-query/codegen-openapi and apply them for another createApi instance in another project?

smff avatar Jan 28 '25 16:01 smff

I’m experiencing the same problem and notice there hasn’t been any progress on it. Could you please update me if there has been any progress?

moonrtv avatar Feb 02 '25 16:02 moonrtv

Yeah, I apologize if I sound frustrated but it seems completely bizarre to me that the code-gen depends on an empty api that you then can't manipulate at all? We wanted to publish it as an npm package that but that's a non-starter because it's missing what seems like a very simple thing like api.setBaseQuery so that we can change the baseUrl depending on the environment we're working against.

This will make updating our API very manual.

kyle-sawatsky avatar Mar 31 '25 21:03 kyle-sawatsky

@kyle-sawatsky if updating the base URL is the only concern, then the best approach is to initialize the API with a custom base query that reads the URL from the Redux store:

  • https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#constructing-a-dynamic-base-url-using-redux-state

markerikson avatar Mar 31 '25 22:03 markerikson

We need custom handling in the base query as well which the npm package shouldn't know about and require access to client code. Even in the example given it's importing a type and a selector which then become a dependency of whatever's using this package. Basically there isn't a good way of injecting this sort of config without the package knowing things it shouldn't have to know.

I think the original ask is what makes the most sense, that the code-gen should just produce the endpoints that can be passed to injectEndpoints.

I believe for now we will have to move forward with integrating the api into the client with the programmatic support.

kyle-sawatsky avatar Mar 31 '25 23:03 kyle-sawatsky

it's missing what seems like a very simple thing like api.setBaseQuery

JavaScript is a very dynamic language, if you want that feature, you can add it - no changes in the library needed.

let myBaseQuery

const api = createApi({
  baseQuery(...args) { 
    return myBaseQuery(args) 
  },
  // ...
})
api.setBaseQuery = baseQuery => { myBaseQuery = baseQuery }

I think the original ask is what makes the most sense, that the code-gen should just produce the endpoints that can be passed to injectEndpoints.

That would result in an absurdly complex type definition being written out for these endpoints, probably a lot longer than the actual runtime code.

Publishing api packages to npm was just not a design goal (not a lot of people do that, so nobody asked for it in the beginning), and adding it now would require a redesign and rewrite of a majority of the library.

phryneas avatar Apr 01 '25 21:04 phryneas

we have a really siimilar use case. Since we're adding a custom token from the redux store to the API requests, we use a custom fetchBaseQuery in createApi which implements a prepareHeaders function. We also try to generate API libs (more concise: a backend REST-Service with matching frontend API clients) where we have to inject the headers. Since the lib does not know whether it's used in a modified header context we cannot do the injection in the lib, but have to do it in the final application.

fjakop avatar Apr 07 '25 10:04 fjakop

@fjakop the code snippet above isn't working for you?

phryneas avatar Apr 07 '25 18:04 phryneas

@moonrtv so, our decision was to add additional step in api generation Firstly, we generate api with original codegen Then we modify these strings with self-made script (working with AST)

import { api } from "../api";
const injectedRtkApi = api.injectEndpoints({

to these

import { EndpointBuilder } from '@reduxjs/toolkit/query';
export const applyRESTApi = (build: EndpointBuilder<any, any, any>) => ({

and those open the road to use generated api in other project by this:

import { applyRESTApi } from '@some/rest-api/rtkq';

export const baseRESTApi = createApi({
  reducerPath: 'RESTApi',
  baseQuery: fetchBaseQuery,
  endpoints: applyRESTApi
});

@markerikson @phryneas This decision is flexible and compatible with TS If you're interested, we can show you the full script so that you maybe could use the idea in the original codegen (two modes: project api and shared api)

smff avatar Apr 08 '25 05:04 smff

@fjakop the code snippet above isn't working for you?

@phryneas unfortunately not, given a library with the following

rtkQueryApiTemplate.ts

import {BaseQueryFn, createApi} from '@reduxjs/toolkit/query/react';

// @ts-ignore
let myBaseQuery

// initialize an empty api service that we'll inject endpoints into later as needed
const rawApi = createApi({
  reducerPath: 'libraryApiGenerated',
  // @ts-ignore
  baseQuery: (...args) => {
    // @ts-ignore
    return myBaseQuery(args)
},
  endpoints: () => ({}),
});

const setBaseQuery: ((baseQuery: BaseQueryFn) => void) = baseQuery => {
  myBaseQuery = baseQuery;
};

const api = {
  ...rawApi, setBaseQuery,
};

export {api};

enhancedBackendApi.ts

import {enhancedApi} from '../generated/rtkQueryApiGenerated';

export const TagEnum = {
  A: "A",
  B: "B"
};

export const enhancedBackendApi = enhancedApi
  .enhanceEndpoints({
    addTagTypes: [
      TagEnum.A,
      TagEnum.B
    ],
    endpoints: {
      getA: {
        providesTags: [TagEnum.A]
      },
      getB: {
        providesTags: [TagEnum.B]
      }
    }
  });

export * from "../generated/rtkQueryApiGenerated";

and in my application

rtkQueryApiTemplate.ts

import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';

export const pimpedBaseQuery = fetchBaseQuery({
  baseUrl: '/',
  prepareHeaders: async headers => {
    // so something
  },
});

// initialize an empty api service that we'll inject endpoints into later as needed
export const api = createApi({
  reducerPath: 'rtkQueryApiGenerated',
  baseQuery: pimpedBaseQuery,
  endpoints: () => ({}),
});

enhancedBackendApi.ts

import {itboApi} from '../generated/rtkQueryApiGenerated.ts';
import {pimpedBaseQuery} from '@api/backend/rtkQueryApiTemplate.ts';
import {enhancedBackendApi as libraryApi} from '@my-org/my-lib';

libraryApi.setBaseQuery(pimpedBaseQuery);

will not work, since setBaseQuery seems to be stripped off by calling injectEndpoints() or enhanceEndpoints() in the library.

fjakop avatar Apr 08 '25 10:04 fjakop

injectEndpoint just returns this with new types, so it will be there at runtime (note that I assigned a property on it, not create a new object).

But of course you could also just

export const setBaseQuery: ((baseQuery: BaseQueryFn) => void) = baseQuery => {
  myBaseQuery = baseQuery;
};

phryneas avatar Apr 08 '25 17:04 phryneas

@phryneas thanks a lot for the clarification, I finally found a working, viable, type-safe solution. For all others affected by this issue, I will share it here.

Consider an api in a shared library, we implement

rtkQueryApiTemplate.ts
import {BaseQueryFn, createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';

// a placeholder for BaseQueryFn injection. Every using application MUST set a BaseQueryFn.
let injectedBaseQuery: BaseQueryFn = () => {
  // return fetchBaseQuery({})
  throw new Error('No BaseQueryFn set, did you call setBaseQuery(baseQuery: BaseQueryFn) for this api?');
};

// initialize an empty api service that we'll inject endpoints into later as needed
const api = createApi({
  // the reducer path should be unique across all APIs
  reducerPath: 'libraryApi',
  // implement injection of BaseQueryFn, so each caller can use its own BaseQueryFn, e.g. for request headers modification
  baseQuery: (args, api, extraOptions) => {
    return injectedBaseQuery(args, api, extraOptions);
  },
  endpoints: () => ({}),
});

// enrich the API with a setter for BaseQueryFn
export const setBaseQueryInternal = (baseQuery: BaseQueryFn) => {
  injectedBaseQuery = baseQuery;
};

export {api};
enhancedBackendApi.ts (if any)
import {enhancedApi} from '../generated/rtkQueryApiGenerated';
import {BaseQueryFn} from '@reduxjs/toolkit/query/react';
import {setBaseQueryInternal} from './rtkQueryApiTemplate';

export const TagEnum = {
  // define tag types if any
};

export const libraryApi = enhancedApi
  .enhanceEndpoints({
    addTagTypes: [
     // add tag types if any
    ],
    endpoints: {
     // enhance endpoints if necessary
    },
  });

/**
 * Sets the BaseQueryFn for this API. Useful to modify request headers etc. where the values are only known to the
 * calling application of the API.
 * @param baseQuery the BaseQueryFn
 */
export const setBaseQuery = (baseQuery: BaseQueryFn) => {
  setBaseQueryInternal(baseQuery);
};

export * from '../generated/rtkQueryApiGenerated';
export const {
  // here come the hooks, if used
} = libraryApi;

So the using application can implement

rtkQueryApiTemplate.ts
import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';

export const customBaseQuery = fetchBaseQuery({
  baseUrl: '/my/base/path',
  prepareHeaders: async headers => {
    // modify headers
  },
});

// create the application's api with custom base query
export const applicationApi = createApi({
  reducerPath: 'applicationApi',
  baseQuery: customBaseQuery,
  endpoints: () => ({}),
});
enhancedBackendApi.ts
import {enhancedApi} from '../generated/rtkQueryApiGenerated.ts';
import {setBaseQuery} from '@my-org/my-lib';
import {customBaseQuery} from './rtkQueryApiTemplate.ts';

// inject the same base query which we use for the application's own api
setBaseQuery(customBaseQuery);

// further enhancement of application's own api

and don't forget to initialize your store accordingly

store/index.ts
import {combineReducers, configureStore} from '@reduxjs/toolkit';
import {setupListeners} from '@reduxjs/toolkit/query';
import {applicationApi} from '../api';
import {libraryApi} from '@my-org/my-lib';

const reducer = combineReducers({
  [applicationApi.reducerPath]: applicationApi.reducer,
  [libraryApi.reducerPath]: libraryApi.reducer,
  // application's own reducers
  // ...
});

const store = configureStore({
  reducer,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware()
      .concat(applicationApi.middleware)
      .concat(libraryApi.middleware)
      // other middlewares
      // ...
});

setupListeners(store.dispatch);

export default store;

fjakop avatar Apr 09 '25 11:04 fjakop

Just another question to complete this topic (hopefully). Since the production code is now working as expected, I tried to unit-test the BaseQuery injection. Asserting the call of the injected function works as expected, but the result is still in 'pending' state after the query hook execution (see code below). To get the result into 'fulfilled' state I had to dispatch an arbitrary action aon the store. Is this how it is expected to be?

Unit test
import {describe, expect, it, vi} from 'vitest';
import {
  libraryApi,
  setBaseQuery,
  useGetAnythingQuery,
} from './index';
import {act, renderHook} from '@testing-library/react';
import {BaseQueryResult} from '@reduxjs/toolkit/query';
import {Action, combineReducers, configureStore, Store} from '@reduxjs/toolkit';
import React from 'react';
import {Provider} from 'react-redux';

const reducer = combineReducers({
  [libraryApi.reducerPath]: libraryApi.reducer,
});

const store = configureStore({
  reducer,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware()
      .concat(libraryApi.middleware),
});

const getWrapper = (store: Store<any, Action>): React.FC => {
  return ({children}: {children?: React.ReactNode}) => <Provider store={store}>{children}</Provider>;
};

describe('api', async () => {

  it('should call injected baseQuery', async () => {
    const baseQuery = vi.fn().mockResolvedValue({data: 'baseQueryReturnValue'});
    // inject the BaseQueryFn
    setBaseQuery(baseQuery);

    const {result}: {result: BaseQueryResult<any>} = await act(() => {
      return act(async () => {
        return renderHook(() => useGetAnythingQuery(), {wrapper: getWrapper(store)});
      });
    });

    expect(result.current.isError).toBeFalsy();
    expect(baseQuery).toHaveBeenCalledOnce();

    // is still 'pending' here, why?
    console.log(result.current.status);

    // after dispatching any unrelated action to the store ...
    await act(async () => {
      store.dispatch({
        type: 'does not matter',
      });
    });

    // we observe a 'fullfilled' status
    console.log(result.current.status);
    // and data is defined
    expect(result.current.data).toEqual('baseQueryReturnValue');
  });

});

fjakop avatar Apr 11 '25 10:04 fjakop