redux-toolkit
redux-toolkit copied to clipboard
How to share generated client from @rtk-query/codegen-openapi ?
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?
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?
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 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
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.
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.
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 the code snippet above isn't working for you?
@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)
@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.
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 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;
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');
});
});