@hypequery/react 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ import { type UseQueryOptions as TanstackUseQueryOptions, type UseMutationOptions as TanstackUseMutationOptions, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query';
2
+ import type { ExtractNames, QueryInput, QueryOutput } from './types.js';
3
+ import { HttpError } from './errors.js';
4
+ export interface QueryMethodConfig {
5
+ method?: string;
6
+ }
7
+ export interface CreateHooksConfig<TApi = Record<string, {
8
+ input: unknown;
9
+ output: unknown;
10
+ }>> {
11
+ baseUrl: string;
12
+ fetchFn?: typeof fetch;
13
+ headers?: Record<string, string>;
14
+ config?: Record<string, QueryMethodConfig>;
15
+ api?: TApi;
16
+ }
17
+ declare const OPTIONS_SYMBOL: unique symbol;
18
+ export declare function queryOptions<T extends object>(opts: T): T & {
19
+ [OPTIONS_SYMBOL]: true;
20
+ };
21
+ export declare function createHooks<Api extends Record<string, {
22
+ input: any;
23
+ output: any;
24
+ }>>(config: CreateHooksConfig<Api>): {
25
+ readonly useQuery: <Name extends ExtractNames<Api>>(...args: QueryInput<Api, Name> extends never ? [name: Name, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined] : [name: Name, input: QueryInput<Api, Name>, options?: Omit<TanstackUseQueryOptions<QueryOutput<Api, Name>, HttpError, QueryOutput<Api, Name>, QueryInput<Api, Name> extends never ? ["hypequery", Name] : ["hypequery", Name, QueryInput<Api, Name>]>, "queryKey" | "queryFn"> | undefined]) => UseQueryResult<QueryOutput<Api, Name>, HttpError>;
26
+ readonly useMutation: <Name extends ExtractNames<Api>>(name: Name, options?: Omit<TanstackUseMutationOptions<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>, unknown>, "mutationFn">) => UseMutationResult<QueryOutput<Api, Name>, HttpError, QueryInput<Api, Name>>;
27
+ };
28
+ export {};
29
+ //# sourceMappingURL=createHooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.tsx"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,eAAe,IAAI,uBAAuB,EAC/C,KAAK,kBAAkB,IAAI,0BAA0B,EACrD,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB,CAAC,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;IAC3F,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC3C,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,QAAA,MAAM,cAAc,eAAkC,CAAC;AAEvD,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG;IAAE,CAAC,cAAc,CAAC,EAAE,IAAI,CAAA;CAAE,CAEtF;AA6DD,wBAAgB,WAAW,CAAC,GAAG,SAAS,MAAM,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EACjF,MAAM,EAAE,iBAAiB,CAAC,GAAG,CAAC;wBAkEZ,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,+kBAE7C,cAAc,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC;2BAwC/B,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,QAC3C,IAAI,kIAET,iBAAiB,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;EAW/E"}
@@ -0,0 +1,133 @@
1
+ import { useQuery as useTanstackQuery, useMutation as useTanstackMutation, } from '@tanstack/react-query';
2
+ import { HttpError } from './errors.js';
3
+ const OPTIONS_SYMBOL = Symbol.for('hypequery-options');
4
+ export function queryOptions(opts) {
5
+ return { ...opts, [OPTIONS_SYMBOL]: true };
6
+ }
7
+ const normalizeMethodConfig = (source) => {
8
+ if (!source)
9
+ return {};
10
+ return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, { method: value.method ?? 'GET' }]));
11
+ };
12
+ const deriveMethodConfig = (api) => {
13
+ if (typeof api !== 'object' || api === null) {
14
+ return {};
15
+ }
16
+ if ('_routeConfig' in api &&
17
+ typeof api._routeConfig === 'object' &&
18
+ api._routeConfig !== null) {
19
+ return normalizeMethodConfig(api._routeConfig);
20
+ }
21
+ if ('queries' in api &&
22
+ typeof api.queries === 'object' &&
23
+ api.queries !== null) {
24
+ return normalizeMethodConfig(api.queries);
25
+ }
26
+ return {};
27
+ };
28
+ const buildUrl = (baseUrl, name) => {
29
+ if (!baseUrl) {
30
+ throw new Error('baseUrl is required');
31
+ }
32
+ return baseUrl.endsWith('/') ? `${baseUrl}${name}` : `${baseUrl}/${name}`;
33
+ };
34
+ const parseResponse = async (res) => {
35
+ const text = await res.text();
36
+ try {
37
+ return JSON.parse(text);
38
+ }
39
+ catch {
40
+ return text;
41
+ }
42
+ };
43
+ const isOptionsBag = (value) => {
44
+ return Boolean(value && typeof value === 'object' && OPTIONS_SYMBOL in value);
45
+ };
46
+ const looksLikeQueryOptions = (value) => {
47
+ if (isOptionsBag(value))
48
+ return true;
49
+ if (typeof value !== 'object' || value === null)
50
+ return false;
51
+ const optionKeys = ['enabled', 'staleTime', 'gcTime', 'refetchInterval', 'refetchOnWindowFocus', 'retry', 'retryDelay'];
52
+ const matches = optionKeys.filter((key) => key in value).length;
53
+ return matches >= 2;
54
+ };
55
+ export function createHooks(config) {
56
+ const { baseUrl, fetchFn = fetch, headers = {}, config: explicitConfig = {}, api } = config;
57
+ const finalConfig = { ...deriveMethodConfig(api), ...explicitConfig };
58
+ const fetchQuery = async (name, input, defaultMethod = 'GET') => {
59
+ const url = buildUrl(baseUrl, name);
60
+ const method = finalConfig[name]?.method ?? defaultMethod;
61
+ let finalUrl = url;
62
+ let body;
63
+ if (method === 'GET' && input && typeof input === 'object') {
64
+ const params = new URLSearchParams();
65
+ for (const [key, value] of Object.entries(input)) {
66
+ if (value === undefined || value === null)
67
+ continue;
68
+ if (Array.isArray(value)) {
69
+ value.forEach((item) => params.append(key, String(item)));
70
+ }
71
+ else {
72
+ params.append(key, String(value));
73
+ }
74
+ }
75
+ const queryString = params.toString();
76
+ finalUrl = queryString ? `${url}?${queryString}` : url;
77
+ }
78
+ else if (input !== undefined) {
79
+ body = JSON.stringify(input);
80
+ }
81
+ const res = await fetchFn(finalUrl, {
82
+ method,
83
+ headers: {
84
+ ...headers,
85
+ ...(body ? { 'content-type': 'application/json' } : {}),
86
+ },
87
+ body,
88
+ });
89
+ if (!res.ok) {
90
+ const errorBody = await parseResponse(res);
91
+ throw new HttpError(`${method} request to ${finalUrl} failed with status ${res.status}`, res.status, errorBody);
92
+ }
93
+ return res.json();
94
+ };
95
+ function useQuery(...args) {
96
+ const [name, potentialInputOrOptions, maybeOptions] = args;
97
+ let input;
98
+ let options;
99
+ if (args.length === 1) {
100
+ input = undefined;
101
+ options = undefined;
102
+ }
103
+ else if (args.length === 2 && looksLikeQueryOptions(potentialInputOrOptions)) {
104
+ input = undefined;
105
+ options = potentialInputOrOptions;
106
+ }
107
+ else {
108
+ input = potentialInputOrOptions;
109
+ options = maybeOptions;
110
+ }
111
+ const queryKey = (() => {
112
+ if (input === undefined) {
113
+ return ['hypequery', name];
114
+ }
115
+ return ['hypequery', name, input];
116
+ })();
117
+ return useTanstackQuery({
118
+ queryKey,
119
+ queryFn: () => fetchQuery(name, input),
120
+ ...(options ?? {}),
121
+ });
122
+ }
123
+ function useMutation(name, options) {
124
+ return useTanstackMutation({
125
+ mutationFn: (input) => fetchQuery(name, input, 'POST'),
126
+ ...options,
127
+ });
128
+ }
129
+ return {
130
+ useQuery,
131
+ useMutation,
132
+ };
133
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=createHooks.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createHooks.test.d.ts","sourceRoot":"","sources":["../src/createHooks.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,558 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { renderHook, waitFor } from '@testing-library/react';
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
+ import { createHooks, queryOptions } from './createHooks.js';
6
+ import { HttpError } from './errors.js';
7
+ function createWrapper() {
8
+ const queryClient = new QueryClient({
9
+ defaultOptions: {
10
+ queries: { retry: false },
11
+ mutations: { retry: false },
12
+ },
13
+ });
14
+ return ({ children }) => (_jsx(QueryClientProvider, { client: queryClient, children: children }));
15
+ }
16
+ function mockSuccessResponse(data) {
17
+ return {
18
+ ok: true,
19
+ status: 200,
20
+ json: () => Promise.resolve(data),
21
+ };
22
+ }
23
+ function mockErrorResponse(status, body) {
24
+ return {
25
+ ok: false,
26
+ status,
27
+ text: () => Promise.resolve(JSON.stringify(body)),
28
+ };
29
+ }
30
+ describe('createHooks', () => {
31
+ describe('Basic functionality', () => {
32
+ const fetchMock = vi.fn();
33
+ const { useQuery, useMutation } = createHooks({
34
+ baseUrl: 'https://example.com/api',
35
+ fetchFn: fetchMock,
36
+ });
37
+ beforeEach(() => {
38
+ fetchMock.mockReset();
39
+ });
40
+ it('executes queries via fetch', async () => {
41
+ fetchMock.mockResolvedValue(mockSuccessResponse({ total: 42 }));
42
+ const { result } = renderHook(() => useQuery('weeklyRevenue', { startDate: '2025-01-01' }), {
43
+ wrapper: createWrapper(),
44
+ });
45
+ await waitFor(() => {
46
+ expect(result.current.data).toEqual({ total: 42 });
47
+ });
48
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/weeklyRevenue?startDate=2025-01-01', expect.objectContaining({ method: 'GET' }));
49
+ });
50
+ it('executes mutations via fetch', async () => {
51
+ fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
52
+ const { result } = renderHook(() => useMutation('rebuildMetrics'), {
53
+ wrapper: createWrapper()
54
+ });
55
+ await result.current.mutateAsync({ force: true });
56
+ // Mutations default to POST
57
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/rebuildMetrics', expect.objectContaining({
58
+ method: 'POST',
59
+ body: JSON.stringify({ force: true }),
60
+ }));
61
+ });
62
+ it('allows queries with no input parameters', async () => {
63
+ fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
64
+ const { result } = renderHook(() => useQuery('noInput'), {
65
+ wrapper: createWrapper(),
66
+ });
67
+ await waitFor(() => expect(result.current.data).toEqual({ status: 'ok' }));
68
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/noInput', expect.objectContaining({ method: 'GET' }));
69
+ });
70
+ });
71
+ describe('HTTP Method Handling', () => {
72
+ const fetchMock = vi.fn();
73
+ beforeEach(() => {
74
+ fetchMock.mockReset();
75
+ fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
76
+ });
77
+ it('defaults to GET when no config provided', async () => {
78
+ const { useQuery } = createHooks({
79
+ baseUrl: 'https://example.com/api',
80
+ fetchFn: fetchMock,
81
+ });
82
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
83
+ wrapper: createWrapper(),
84
+ });
85
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
86
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('?'), expect.objectContaining({ method: 'GET' }));
87
+ });
88
+ it('uses POST when explicitly configured', async () => {
89
+ const { useQuery } = createHooks({
90
+ baseUrl: 'https://example.com/api',
91
+ fetchFn: fetchMock,
92
+ config: {
93
+ getUser: { method: 'POST' },
94
+ },
95
+ });
96
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
97
+ wrapper: createWrapper(),
98
+ });
99
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
100
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
101
+ method: 'POST',
102
+ body: JSON.stringify({ id: '123' }),
103
+ }));
104
+ });
105
+ it('extracts method config from API object', async () => {
106
+ const mockApi = {
107
+ queries: {
108
+ getUser: { method: 'GET' },
109
+ updateUser: { method: 'PATCH' },
110
+ },
111
+ };
112
+ const { useQuery } = createHooks({
113
+ baseUrl: 'https://example.com/api',
114
+ fetchFn: fetchMock,
115
+ api: mockApi,
116
+ });
117
+ const { result } = renderHook(() => useQuery('updateUser', { id: '123', name: 'John' }), {
118
+ wrapper: createWrapper(),
119
+ });
120
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
121
+ expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'PATCH' }));
122
+ });
123
+ it('prefers route-level config over endpoint config', async () => {
124
+ const mockApi = {
125
+ queries: {
126
+ getUser: { method: 'POST' },
127
+ },
128
+ _routeConfig: {
129
+ getUser: { method: 'GET' },
130
+ },
131
+ };
132
+ const { useQuery } = createHooks({
133
+ baseUrl: 'https://example.com/api',
134
+ fetchFn: fetchMock,
135
+ api: mockApi,
136
+ });
137
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
138
+ wrapper: createWrapper(),
139
+ });
140
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
141
+ expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'GET' }));
142
+ });
143
+ it('allows explicit config to override API config', async () => {
144
+ const mockApi = {
145
+ queries: {
146
+ getUser: { method: 'GET' },
147
+ },
148
+ };
149
+ const { useQuery } = createHooks({
150
+ baseUrl: 'https://example.com/api',
151
+ fetchFn: fetchMock,
152
+ api: mockApi,
153
+ config: {
154
+ getUser: { method: 'POST' },
155
+ },
156
+ });
157
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
158
+ wrapper: createWrapper(),
159
+ });
160
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
161
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({ method: 'POST' }));
162
+ });
163
+ });
164
+ describe('Query Parameter Serialization', () => {
165
+ const fetchMock = vi.fn();
166
+ beforeEach(() => {
167
+ fetchMock.mockReset();
168
+ fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
169
+ });
170
+ it('serializes simple objects as query params for GET requests', async () => {
171
+ const { useQuery } = createHooks({
172
+ baseUrl: 'https://example.com/api',
173
+ fetchFn: fetchMock,
174
+ });
175
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
176
+ wrapper: createWrapper(),
177
+ });
178
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
179
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser?id=123', expect.objectContaining({ method: 'GET' }));
180
+ });
181
+ it('serializes arrays as multiple query params', async () => {
182
+ const { useQuery } = createHooks({
183
+ baseUrl: 'https://example.com/api',
184
+ fetchFn: fetchMock,
185
+ });
186
+ const { result } = renderHook(() => useQuery('listItems', { tags: ['react', 'typescript'], limit: 10 }), { wrapper: createWrapper() });
187
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
188
+ const callUrl = fetchMock.mock.calls[0][0];
189
+ expect(callUrl).toContain('tags=react');
190
+ expect(callUrl).toContain('tags=typescript');
191
+ expect(callUrl).toContain('limit=10');
192
+ });
193
+ it('skips undefined and null values in query params', async () => {
194
+ const { useQuery } = createHooks({
195
+ baseUrl: 'https://example.com/api',
196
+ fetchFn: fetchMock,
197
+ });
198
+ const { result } = renderHook(() => useQuery('rebuildMetrics', { force: undefined }), { wrapper: createWrapper() });
199
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
200
+ const callUrl = fetchMock.mock.calls[0][0];
201
+ expect(callUrl).not.toContain('force');
202
+ });
203
+ it('handles empty objects in GET requests', async () => {
204
+ const { useQuery } = createHooks({
205
+ baseUrl: 'https://example.com/api',
206
+ fetchFn: fetchMock,
207
+ });
208
+ const { result } = renderHook(() => useQuery('rebuildMetrics', {}), { wrapper: createWrapper() });
209
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
210
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/rebuildMetrics', expect.objectContaining({ method: 'GET' }));
211
+ });
212
+ it('uses JSON body for POST requests', async () => {
213
+ const { useQuery } = createHooks({
214
+ baseUrl: 'https://example.com/api',
215
+ fetchFn: fetchMock,
216
+ config: {
217
+ getUser: { method: 'POST' },
218
+ },
219
+ });
220
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
221
+ wrapper: createWrapper(),
222
+ });
223
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
224
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/getUser', expect.objectContaining({
225
+ method: 'POST',
226
+ body: JSON.stringify({ id: '123' }),
227
+ headers: expect.objectContaining({
228
+ 'content-type': 'application/json',
229
+ }),
230
+ }));
231
+ });
232
+ });
233
+ describe('Error Handling', () => {
234
+ const fetchMock = vi.fn();
235
+ beforeEach(() => {
236
+ fetchMock.mockReset();
237
+ });
238
+ it('throws error with detailed message on failed request', async () => {
239
+ fetchMock.mockResolvedValue(mockErrorResponse(404, { message: 'Not found' }));
240
+ const { useQuery } = createHooks({
241
+ baseUrl: 'https://example.com/api',
242
+ fetchFn: fetchMock,
243
+ });
244
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
245
+ wrapper: createWrapper(),
246
+ });
247
+ await waitFor(() => expect(result.current.isError).toBe(true));
248
+ expect(result.current.error).toBeInstanceOf(HttpError);
249
+ expect(result.current.error?.message).toContain('GET request to');
250
+ expect(result.current.error?.message).toContain('failed with status 404');
251
+ expect(result.current.error?.status).toBe(404);
252
+ expect(result.current.error?.body).toEqual({ message: 'Not found' });
253
+ });
254
+ it('parses JSON error responses', async () => {
255
+ fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Bad request' }));
256
+ const { useQuery } = createHooks({
257
+ baseUrl: 'https://example.com/api',
258
+ fetchFn: fetchMock,
259
+ });
260
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
261
+ wrapper: createWrapper(),
262
+ });
263
+ await waitFor(() => expect(result.current.isError).toBe(true));
264
+ expect(result.current.error).toBeInstanceOf(HttpError);
265
+ expect(result.current.error?.body).toEqual({ error: 'Bad request' });
266
+ });
267
+ it('parses text error responses', async () => {
268
+ fetchMock.mockResolvedValue({
269
+ ok: false,
270
+ status: 500,
271
+ text: () => Promise.resolve('Internal server error'),
272
+ });
273
+ const { useQuery } = createHooks({
274
+ baseUrl: 'https://example.com/api',
275
+ fetchFn: fetchMock,
276
+ });
277
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
278
+ wrapper: createWrapper(),
279
+ });
280
+ await waitFor(() => expect(result.current.isError).toBe(true));
281
+ expect(result.current.error).toBeInstanceOf(HttpError);
282
+ expect(result.current.error?.body).toBe('Internal server error');
283
+ });
284
+ it('handles network errors', async () => {
285
+ fetchMock.mockRejectedValue(new Error('Network error'));
286
+ const { useQuery } = createHooks({
287
+ baseUrl: 'https://example.com/api',
288
+ fetchFn: fetchMock,
289
+ });
290
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
291
+ wrapper: createWrapper(),
292
+ });
293
+ await waitFor(() => expect(result.current.isError).toBe(true));
294
+ expect(result.current.error?.message).toBe('Network error');
295
+ });
296
+ });
297
+ describe('useQuery Argument Overloads', () => {
298
+ const fetchMock = vi.fn();
299
+ beforeEach(() => {
300
+ fetchMock.mockReset();
301
+ fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
302
+ });
303
+ it('handles useQuery(name) with no input', async () => {
304
+ const { useQuery } = createHooks({
305
+ baseUrl: 'https://example.com/api',
306
+ fetchFn: fetchMock,
307
+ });
308
+ const { result } = renderHook(() => useQuery('noInput'), {
309
+ wrapper: createWrapper(),
310
+ });
311
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
312
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/noInput', expect.any(Object));
313
+ });
314
+ it('handles useQuery(name, input)', async () => {
315
+ const { useQuery } = createHooks({
316
+ baseUrl: 'https://example.com/api',
317
+ fetchFn: fetchMock,
318
+ });
319
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
320
+ wrapper: createWrapper(),
321
+ });
322
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
323
+ expect(fetchMock).toHaveBeenCalled();
324
+ });
325
+ it('handles useQuery(name, options) - options only', async () => {
326
+ const { useQuery } = createHooks({
327
+ baseUrl: 'https://example.com/api',
328
+ fetchFn: fetchMock,
329
+ });
330
+ const { result } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 5000 }), { wrapper: createWrapper() });
331
+ // Should not fetch because enabled: false
332
+ expect(result.current.isSuccess).toBe(false);
333
+ expect(fetchMock).not.toHaveBeenCalled();
334
+ });
335
+ it('handles useQuery(name, input, options)', async () => {
336
+ const { useQuery } = createHooks({
337
+ baseUrl: 'https://example.com/api',
338
+ fetchFn: fetchMock,
339
+ });
340
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }, { staleTime: 5000 }), { wrapper: createWrapper() });
341
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
342
+ expect(fetchMock).toHaveBeenCalled();
343
+ });
344
+ it('correctly distinguishes input from options using isQueryOptions', async () => {
345
+ const { useQuery } = createHooks({
346
+ baseUrl: 'https://example.com/api',
347
+ fetchFn: fetchMock,
348
+ });
349
+ // Input with 'enabled' field should be treated as input, not options
350
+ const { result: result1 } = renderHook(() => useQuery('rebuildMetrics', { force: true }), { wrapper: createWrapper() });
351
+ await waitFor(() => expect(result1.current.isSuccess).toBe(true));
352
+ expect(fetchMock).toHaveBeenCalled();
353
+ fetchMock.mockClear();
354
+ // Options object should disable the query
355
+ const { result: result2 } = renderHook(() => useQuery('noInput', { enabled: false, staleTime: 1000 }), { wrapper: createWrapper() });
356
+ expect(fetchMock).not.toHaveBeenCalled();
357
+ });
358
+ it('uses queryOptions() helper for explicit option marking', async () => {
359
+ const { useQuery } = createHooks({
360
+ baseUrl: 'https://example.com/api',
361
+ fetchFn: fetchMock,
362
+ });
363
+ // Using queryOptions() helper ensures options are recognized
364
+ const { result } = renderHook(() => useQuery('noInput', queryOptions({ enabled: false })), { wrapper: createWrapper() });
365
+ // Query should be disabled
366
+ expect(result.current.isLoading).toBe(false);
367
+ expect(fetchMock).not.toHaveBeenCalled();
368
+ });
369
+ });
370
+ describe('Configuration', () => {
371
+ const fetchMock = vi.fn();
372
+ beforeEach(() => {
373
+ fetchMock.mockReset();
374
+ fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
375
+ });
376
+ it('includes custom headers in requests', async () => {
377
+ const { useQuery } = createHooks({
378
+ baseUrl: 'https://example.com/api',
379
+ fetchFn: fetchMock,
380
+ headers: {
381
+ 'Authorization': 'Bearer token123',
382
+ 'X-Custom': 'value',
383
+ },
384
+ });
385
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
386
+ wrapper: createWrapper(),
387
+ });
388
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
389
+ expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
390
+ headers: expect.objectContaining({
391
+ 'Authorization': 'Bearer token123',
392
+ 'X-Custom': 'value',
393
+ }),
394
+ }));
395
+ });
396
+ it('handles baseUrl with trailing slash', async () => {
397
+ const { useQuery } = createHooks({
398
+ baseUrl: 'https://example.com/api/',
399
+ fetchFn: fetchMock,
400
+ });
401
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
402
+ wrapper: createWrapper(),
403
+ });
404
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
405
+ const callUrl = fetchMock.mock.calls[0][0];
406
+ expect(callUrl).toContain('https://example.com/api/getUser');
407
+ expect(callUrl).not.toContain('//getUser');
408
+ });
409
+ it('handles baseUrl without trailing slash', async () => {
410
+ const { useQuery } = createHooks({
411
+ baseUrl: 'https://example.com/api',
412
+ fetchFn: fetchMock,
413
+ });
414
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
415
+ wrapper: createWrapper(),
416
+ });
417
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
418
+ const callUrl = fetchMock.mock.calls[0][0];
419
+ expect(callUrl).toContain('https://example.com/api/getUser');
420
+ });
421
+ it('throws error when baseUrl is missing', () => {
422
+ expect(() => {
423
+ createHooks({
424
+ baseUrl: '',
425
+ fetchFn: fetchMock,
426
+ });
427
+ }).not.toThrow();
428
+ const { useQuery } = createHooks({
429
+ baseUrl: '',
430
+ fetchFn: fetchMock,
431
+ });
432
+ const { result } = renderHook(() => useQuery('getUser', { id: '123' }), {
433
+ wrapper: createWrapper(),
434
+ });
435
+ // Should throw during query execution
436
+ waitFor(() => {
437
+ expect(result.current.error?.message).toContain('baseUrl is required');
438
+ });
439
+ });
440
+ });
441
+ describe('Edge Cases', () => {
442
+ const fetchMock = vi.fn();
443
+ beforeEach(() => {
444
+ fetchMock.mockReset();
445
+ });
446
+ it('handles empty response body', async () => {
447
+ fetchMock.mockResolvedValue({
448
+ ok: true,
449
+ status: 204,
450
+ json: () => Promise.resolve(null),
451
+ });
452
+ const { useQuery } = createHooks({
453
+ baseUrl: 'https://example.com/api',
454
+ fetchFn: fetchMock,
455
+ });
456
+ const { result } = renderHook(() => useQuery('noInput'), {
457
+ wrapper: createWrapper(),
458
+ });
459
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
460
+ expect(result.current.data).toBe(null);
461
+ });
462
+ it('handles non-JSON text responses', async () => {
463
+ fetchMock.mockResolvedValue({
464
+ ok: true,
465
+ status: 200,
466
+ json: () => Promise.reject(new Error('Not JSON')),
467
+ text: () => Promise.resolve('Plain text response'),
468
+ });
469
+ const { useQuery } = createHooks({
470
+ baseUrl: 'https://example.com/api',
471
+ fetchFn: fetchMock,
472
+ });
473
+ const { result } = renderHook(() => useQuery('noInput'), {
474
+ wrapper: createWrapper(),
475
+ });
476
+ // This will fail because json() rejects, but shows the edge case
477
+ await waitFor(() => expect(result.current.isError).toBe(true));
478
+ });
479
+ it('passes through TanStack Query options correctly', async () => {
480
+ fetchMock.mockResolvedValue(mockSuccessResponse({ status: 'ok' }));
481
+ const { useQuery } = createHooks({
482
+ baseUrl: 'https://example.com/api',
483
+ fetchFn: fetchMock,
484
+ });
485
+ const { result, rerender } = renderHook(({ enabled }) => useQuery('noInput', { enabled, staleTime: 1000 }), {
486
+ wrapper: createWrapper(),
487
+ initialProps: { enabled: false },
488
+ });
489
+ // Should not fetch when disabled (now requires 2+ option keys to be recognized)
490
+ expect(fetchMock).not.toHaveBeenCalled();
491
+ // Enable the query
492
+ rerender({ enabled: true });
493
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
494
+ expect(fetchMock).toHaveBeenCalled();
495
+ });
496
+ });
497
+ describe('useMutation', () => {
498
+ const fetchMock = vi.fn();
499
+ beforeEach(() => {
500
+ fetchMock.mockReset();
501
+ fetchMock.mockResolvedValue(mockSuccessResponse({ success: true }));
502
+ });
503
+ it('defaults to POST method', async () => {
504
+ const { useMutation } = createHooks({
505
+ baseUrl: 'https://example.com/api',
506
+ fetchFn: fetchMock,
507
+ });
508
+ const { result } = renderHook(() => useMutation('updateUser'), {
509
+ wrapper: createWrapper(),
510
+ });
511
+ await result.current.mutateAsync({ id: '123', name: 'John' });
512
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
513
+ method: 'POST',
514
+ body: JSON.stringify({ id: '123', name: 'John' }),
515
+ }));
516
+ });
517
+ it('allows custom HTTP methods via config', async () => {
518
+ const { useMutation } = createHooks({
519
+ baseUrl: 'https://example.com/api',
520
+ fetchFn: fetchMock,
521
+ config: {
522
+ updateUser: { method: 'PATCH' },
523
+ },
524
+ });
525
+ const { result } = renderHook(() => useMutation('updateUser'), {
526
+ wrapper: createWrapper(),
527
+ });
528
+ await result.current.mutateAsync({ id: '123', name: 'John' });
529
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/updateUser', expect.objectContaining({
530
+ method: 'PATCH',
531
+ body: JSON.stringify({ id: '123', name: 'John' }),
532
+ }));
533
+ });
534
+ it('passes through mutation options', async () => {
535
+ const onSuccess = vi.fn();
536
+ const onError = vi.fn();
537
+ const { useMutation } = createHooks({
538
+ baseUrl: 'https://example.com/api',
539
+ fetchFn: fetchMock,
540
+ });
541
+ const { result } = renderHook(() => useMutation('updateUser', { onSuccess, onError }), { wrapper: createWrapper() });
542
+ await result.current.mutateAsync({ id: '123', name: 'John' });
543
+ expect(onSuccess).toHaveBeenCalled();
544
+ expect(onError).not.toHaveBeenCalled();
545
+ });
546
+ it('handles mutation errors', async () => {
547
+ fetchMock.mockResolvedValue(mockErrorResponse(400, { error: 'Invalid data' }));
548
+ const { useMutation } = createHooks({
549
+ baseUrl: 'https://example.com/api',
550
+ fetchFn: fetchMock,
551
+ });
552
+ const { result } = renderHook(() => useMutation('updateUser'), {
553
+ wrapper: createWrapper(),
554
+ });
555
+ await expect(result.current.mutateAsync({ id: '123', name: 'John' })).rejects.toThrow();
556
+ });
557
+ });
558
+ });
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,SAAU,SAAQ,KAAK;aAGhB,MAAM,EAAE,MAAM;aACd,IAAI,EAAE,OAAO;gBAF7B,OAAO,EAAE,MAAM,EACC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO;CAOhC"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createHooks, queryOptions } from './createHooks.js';
2
+ export { HttpError } from './errors.js';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAErE,MAAM,MAAM,YAAY,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE,MAAM,CAAC,CAAC;AAE3D,MAAM,MAAM,UAAU,CACpB,GAAG,EACH,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,IAC5B,GAAG,CAAC,IAAI,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,KAAK,CAAA;CAAE,GAAG,KAAK,GAAG,KAAK,CAAC;AAE7D,MAAM,MAAM,WAAW,CACrB,GAAG,EACH,IAAI,SAAS,YAAY,CAAC,GAAG,CAAC,IAC5B,GAAG,CAAC,IAAI,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,KAAK,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/react",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "React hooks for consuming hypequery APIs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",