@hypequery/react 0.0.1 → 0.0.2

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