@geekmidas/client 0.0.1 → 0.1.0

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,203 @@
1
+ import type {
2
+ QueryClient,
3
+ UseMutationOptions,
4
+ UseQueryOptions,
5
+ } from '@tanstack/react-query';
6
+ import { useMutation, useQuery } from '@tanstack/react-query';
7
+ import { useMemo } from 'react';
8
+ import type {
9
+ ExtractEndpointResponse,
10
+ FilteredRequestConfig,
11
+ IsConfigRequired,
12
+ MutationEndpoint,
13
+ QueryEndpoint,
14
+ TypedApiFunction,
15
+ } from './types';
16
+
17
+ /**
18
+ * Build query key from endpoint and config
19
+ */
20
+ function buildQueryKey<Paths, T extends QueryEndpoint<Paths>>(
21
+ endpoint: T,
22
+ config?: FilteredRequestConfig<Paths, T>,
23
+ ): unknown[] {
24
+ const key: unknown[] = [endpoint];
25
+
26
+ if (config && 'params' in config && config.params) {
27
+ key.push({ params: config.params });
28
+ }
29
+
30
+ if (config && 'query' in config && config.query) {
31
+ key.push({ query: config.query });
32
+ }
33
+
34
+ return key;
35
+ }
36
+
37
+ /**
38
+ * Options for creating endpoint-based hooks
39
+ */
40
+ export interface CreateEndpointHooksOptions {
41
+ queryClient?: QueryClient;
42
+ }
43
+
44
+ /**
45
+ * Hook options type that conditionally requires config
46
+ */
47
+ type UseQueryArgs<Paths, T extends QueryEndpoint<Paths>> = IsConfigRequired<
48
+ Paths,
49
+ T
50
+ > extends true
51
+ ? [
52
+ config: FilteredRequestConfig<Paths, T>,
53
+ options?: Omit<
54
+ UseQueryOptions<ExtractEndpointResponse<Paths, T>, Error>,
55
+ 'queryKey' | 'queryFn'
56
+ >,
57
+ ]
58
+ : [
59
+ config?: FilteredRequestConfig<Paths, T>,
60
+ options?: Omit<
61
+ UseQueryOptions<ExtractEndpointResponse<Paths, T>, Error>,
62
+ 'queryKey' | 'queryFn'
63
+ >,
64
+ ];
65
+
66
+ /**
67
+ * Endpoint-based React Query hooks
68
+ */
69
+ export interface EndpointHooks<Paths> {
70
+ /**
71
+ * Use query hook for GET endpoints.
72
+ * Config is required when endpoint has path params.
73
+ */
74
+ useQuery: <T extends QueryEndpoint<Paths>>(
75
+ endpoint: T,
76
+ ...args: UseQueryArgs<Paths, T>
77
+ ) => ReturnType<typeof useQuery<ExtractEndpointResponse<Paths, T>, Error>>;
78
+
79
+ /**
80
+ * Use mutation hook for POST, PUT, PATCH, DELETE endpoints.
81
+ * Config with params/body is passed to mutate().
82
+ */
83
+ useMutation: <T extends MutationEndpoint<Paths>>(
84
+ endpoint: T,
85
+ options?: Omit<
86
+ UseMutationOptions<
87
+ ExtractEndpointResponse<Paths, T>,
88
+ Error,
89
+ FilteredRequestConfig<Paths, T>
90
+ >,
91
+ 'mutationFn'
92
+ >,
93
+ ) => ReturnType<
94
+ typeof useMutation<
95
+ ExtractEndpointResponse<Paths, T>,
96
+ Error,
97
+ FilteredRequestConfig<Paths, T>
98
+ >
99
+ >;
100
+
101
+ /**
102
+ * Build a query key for manual cache operations
103
+ */
104
+ buildQueryKey: <T extends QueryEndpoint<Paths>>(
105
+ endpoint: T,
106
+ config?: FilteredRequestConfig<Paths, T>,
107
+ ) => unknown[];
108
+ }
109
+
110
+ /**
111
+ * Create endpoint-based React Query hooks from a typed fetcher.
112
+ *
113
+ * @example
114
+ * ```typescript
115
+ * const fetcher = createAuthAwareFetcher<paths>({ ... });
116
+ * const hooks = createEndpointHooks<paths>(fetcher);
117
+ *
118
+ * // In a component
119
+ * const { data } = hooks.useQuery('GET /users/{id}', { params: { id: '123' } });
120
+ *
121
+ * const mutation = hooks.useMutation('POST /users');
122
+ * await mutation.mutateAsync({ body: { name: 'John' } });
123
+ * ```
124
+ */
125
+ export function createEndpointHooks<Paths>(
126
+ fetcher: TypedApiFunction<Paths>,
127
+ options: CreateEndpointHooksOptions = {},
128
+ ): EndpointHooks<Paths> {
129
+ return {
130
+ useQuery: <T extends QueryEndpoint<Paths>>(
131
+ endpoint: T,
132
+ ...args: UseQueryArgs<Paths, T>
133
+ ) => {
134
+ // Parse args - config is first, options is second
135
+ const [config, queryOptions] = args as [
136
+ FilteredRequestConfig<Paths, T> | undefined,
137
+ (
138
+ | Omit<
139
+ UseQueryOptions<ExtractEndpointResponse<Paths, T>, Error>,
140
+ 'queryKey' | 'queryFn'
141
+ >
142
+ | undefined
143
+ ),
144
+ ];
145
+
146
+ const queryKey = buildQueryKey(endpoint, config);
147
+
148
+ const memoizedOptions = useMemo(
149
+ () => ({
150
+ queryKey,
151
+ queryFn: () =>
152
+ fetcher(
153
+ endpoint as Parameters<typeof fetcher>[0],
154
+ config as Parameters<typeof fetcher>[1],
155
+ ),
156
+ ...queryOptions,
157
+ }),
158
+ [
159
+ queryKey.join(','),
160
+ endpoint,
161
+ JSON.stringify(config),
162
+ JSON.stringify(queryOptions),
163
+ ],
164
+ );
165
+
166
+ return useQuery<ExtractEndpointResponse<Paths, T>, Error>(
167
+ memoizedOptions,
168
+ );
169
+ },
170
+
171
+ useMutation: <T extends MutationEndpoint<Paths>>(
172
+ endpoint: T,
173
+ mutationOptions?: Omit<
174
+ UseMutationOptions<
175
+ ExtractEndpointResponse<Paths, T>,
176
+ Error,
177
+ FilteredRequestConfig<Paths, T>
178
+ >,
179
+ 'mutationFn'
180
+ >,
181
+ ) => {
182
+ const memoizedOptions = useMemo(
183
+ () => ({
184
+ mutationFn: (config: FilteredRequestConfig<Paths, T>) =>
185
+ fetcher(
186
+ endpoint as Parameters<typeof fetcher>[0],
187
+ config as Parameters<typeof fetcher>[1],
188
+ ),
189
+ ...mutationOptions,
190
+ }),
191
+ [endpoint, JSON.stringify(mutationOptions)],
192
+ );
193
+
194
+ return useMutation<
195
+ ExtractEndpointResponse<Paths, T>,
196
+ Error,
197
+ FilteredRequestConfig<Paths, T>
198
+ >(memoizedOptions);
199
+ },
200
+
201
+ buildQueryKey,
202
+ };
203
+ }
package/src/infer.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type {
2
+ Endpoint,
3
+ EndpointSchemas,
4
+ } from '@geekmidas/constructs/endpoints';
5
+ import type { HttpMethod } from '@geekmidas/constructs/types';
6
+ import type { InferStandardSchema } from '@geekmidas/schema';
7
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
8
+
9
+ /**
10
+ * Infers path parameters from a route string as an object
11
+ * @example '/users/{id}/posts/{postId}' -> { id: string, postId: string }
12
+ */
13
+ type InferPathParams<TRoute extends string> =
14
+ TRoute extends `${string}{${infer Param}}${infer Rest}`
15
+ ? { [K in Param]: string } & InferPathParams<Rest>
16
+ : {};
17
+
18
+ /**
19
+ * Converts an HTTP method to lowercase for TypedFetcher compatibility
20
+ */
21
+ type LowercaseMethod<T extends HttpMethod> = Lowercase<T>;
22
+
23
+ /**
24
+ * Infers route-level parameters (path params)
25
+ */
26
+ type InferRouteParameters<TRoute extends string> =
27
+ InferPathParams<TRoute> extends Record<string, never>
28
+ ? {}
29
+ : {
30
+ parameters: {
31
+ path: InferPathParams<TRoute>;
32
+ };
33
+ };
34
+
35
+ /**
36
+ * Infers operation-level parameters (query params)
37
+ */
38
+ type InferOperationParameters<TInput extends EndpointSchemas> = TInput extends {
39
+ query: infer Q;
40
+ }
41
+ ? {
42
+ parameters: {
43
+ query: InferStandardSchema<Q>;
44
+ };
45
+ }
46
+ : {};
47
+
48
+ /**
49
+ * Infers the operation object compatible with TypedFetcher
50
+ */
51
+ type InferOperation<
52
+ TInput extends EndpointSchemas,
53
+ TOutput extends StandardSchemaV1 | undefined,
54
+ > = InferOperationParameters<TInput> & {
55
+ requestBody?: TInput extends { body: infer B }
56
+ ? {
57
+ content: {
58
+ 'application/json': InferStandardSchema<B>;
59
+ };
60
+ }
61
+ : never;
62
+ responses: {
63
+ 200: {
64
+ content: TOutput extends StandardSchemaV1
65
+ ? {
66
+ 'application/json': InferStandardSchema<TOutput>;
67
+ }
68
+ : never;
69
+ };
70
+ };
71
+ };
72
+
73
+ /**
74
+ * Infers the TypedFetcher-compatible paths structure from a single endpoint
75
+ *
76
+ * This generates a structure compatible with @geekmidas/client TypedFetcher,
77
+ * allowing you to create a typed client directly from endpoint definitions
78
+ * without needing OpenAPI JSON + codegen.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * import { e } from '@geekmidas/constructs';
83
+ * import { createTypedFetcher, type InferOpenApiFromEndpoint } from '@geekmidas/client';
84
+ * import { z } from 'zod';
85
+ *
86
+ * const endpoint = e
87
+ * .get('/users/{id}')
88
+ * .params(z.object({ id: z.string() }))
89
+ * .output(z.object({ id: z.string(), name: z.string() }))
90
+ * .handle(async ({ params }) => ({ id: params.id, name: 'John' }));
91
+ *
92
+ * type Paths = InferOpenApiFromEndpoint<typeof endpoint>['paths'];
93
+ * const client = createTypedFetcher<Paths>({ baseURL: 'http://localhost:3000' });
94
+ * const user = await client('GET /users/{id}', { params: { id: '123' } });
95
+ * ```
96
+ */
97
+ export type InferOpenApiFromEndpoint<T> = T extends Endpoint<
98
+ infer TRoute,
99
+ infer TMethod,
100
+ infer TInput,
101
+ infer TOutput,
102
+ any,
103
+ any,
104
+ any
105
+ >
106
+ ? {
107
+ paths: {
108
+ [K in TRoute]: InferRouteParameters<TRoute> & {
109
+ [M in LowercaseMethod<TMethod>]: InferOperation<TInput, TOutput>;
110
+ };
111
+ };
112
+ }
113
+ : never;
114
+
115
+ /**
116
+ * Infers TypedFetcher-compatible paths structure from multiple endpoints
117
+ *
118
+ * Merges multiple endpoint definitions into a single paths object that can be
119
+ * used with @geekmidas/client TypedFetcher for fully type-safe API calls.
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * import { e } from '@geekmidas/constructs';
124
+ * import { createTypedFetcher, type InferOpenApi } from '@geekmidas/client';
125
+ * import { z } from 'zod';
126
+ *
127
+ * // Define endpoints
128
+ * const getUserEndpoint = e
129
+ * .get('/users/{id}')
130
+ * .params(z.object({ id: z.string() }))
131
+ * .output(z.object({ id: z.string(), name: z.string() }))
132
+ * .handle(async ({ params }) => ({ id: params.id, name: 'John' }));
133
+ *
134
+ * const createUserEndpoint = e
135
+ * .post('/users')
136
+ * .body(z.object({ name: z.string() }))
137
+ * .output(z.object({ id: z.string(), name: z.string() }))
138
+ * .handle(async ({ body }) => ({ id: '123', name: body.name }));
139
+ *
140
+ * // Export for client
141
+ * export const endpoints = [getUserEndpoint, createUserEndpoint] as const;
142
+ * export type Paths = InferOpenApi<typeof endpoints>['paths'];
143
+ *
144
+ * // Client usage
145
+ * import type { Paths } from './endpoints';
146
+ * const client = createTypedFetcher<Paths>({ baseURL: 'http://localhost:3000' });
147
+ *
148
+ * const user = await client('GET /users/{id}', { params: { id: '123' } });
149
+ * const newUser = await client('POST /users', { body: { name: 'Jane' } });
150
+ * ```
151
+ */
152
+ export type InferOpenApi<TEndpoints extends readonly any[]> =
153
+ TEndpoints extends readonly [infer First, ...infer Rest]
154
+ ? InferOpenApiFromEndpoint<First> extends { paths: infer P1 }
155
+ ? Rest extends []
156
+ ? { paths: P1 }
157
+ : InferOpenApi<Rest> extends { paths: infer P2 }
158
+ ? { paths: P1 & P2 }
159
+ : { paths: P1 }
160
+ : InferOpenApi<Rest>
161
+ : { paths: {} };
package/src/types.ts CHANGED
@@ -130,14 +130,75 @@ export type ExtractEndpointConfig<
130
130
  : never
131
131
  : never;
132
132
 
133
- export type FilteredRequestConfig<Paths, T extends EndpointString> = {
134
- [K in keyof ExtractEndpointConfig<Paths, T> as ExtractEndpointConfig<
135
- Paths,
136
- T
137
- >[K] extends never | undefined
138
- ? never
139
- : K]: ExtractEndpointConfig<Paths, T>[K];
140
- };
133
+ /**
134
+ * Build a request config type where:
135
+ * - `params` is required if the endpoint has path parameters
136
+ * - `body` is required if the endpoint has a request body
137
+ * - `query` and `headers` are always optional
138
+ */
139
+ export type FilteredRequestConfig<
140
+ Paths,
141
+ T extends EndpointString,
142
+ > = T extends `${infer Method} ${infer Route}`
143
+ ? Route extends OpenAPIRoutes<Paths>
144
+ ? Lowercase<Method> extends ExtractMethod<Paths, Route>
145
+ ? BuildRequestConfig<
146
+ ExtractPathParams<Paths, Route>,
147
+ ExtractQueryParams<Paths, Route, Lowercase<Method>>,
148
+ ExtractRequestBody<Paths, Route, Lowercase<Method>>
149
+ >
150
+ : never
151
+ : never
152
+ : never;
153
+
154
+ /**
155
+ * Helper to build request config with correct required/optional fields
156
+ */
157
+ type BuildRequestConfig<TParams, TQuery, TBody> = SimplifyIntersection<
158
+ // params: required if not never
159
+ (TParams extends never ? {} : { params: TParams }) &
160
+ // body: required if not never
161
+ (TBody extends never ? {} : { body: TBody }) &
162
+ // query: optional if not never
163
+ (TQuery extends never ? {} : { query?: TQuery }) & {
164
+ // headers: always optional
165
+ headers?: Record<string, string>;
166
+ }
167
+ >;
168
+
169
+ /**
170
+ * Simplify intersection types for better IDE display
171
+ */
172
+ type SimplifyIntersection<T> = { [K in keyof T]: T[K] };
173
+
174
+ /**
175
+ * Check if the config object is required (has any required fields)
176
+ */
177
+ export type IsConfigRequired<
178
+ Paths,
179
+ T extends EndpointString,
180
+ > = T extends `${infer Method} ${infer Route}`
181
+ ? Route extends OpenAPIRoutes<Paths>
182
+ ? Lowercase<Method> extends ExtractMethod<Paths, Route>
183
+ ? ExtractPathParams<Paths, Route> extends never
184
+ ? ExtractRequestBody<Paths, Route, Lowercase<Method>> extends never
185
+ ? false
186
+ : true
187
+ : true
188
+ : false
189
+ : false
190
+ : false;
191
+
192
+ /**
193
+ * Typed function signature for the API client.
194
+ * Config is required when endpoint has path params or body.
195
+ */
196
+ export type TypedApiFunction<Paths> = <T extends TypedEndpoint<Paths>>(
197
+ endpoint: T,
198
+ ...args: IsConfigRequired<Paths, T> extends true
199
+ ? [config: FilteredRequestConfig<Paths, T>]
200
+ : [config?: FilteredRequestConfig<Paths, T>]
201
+ ) => Promise<ExtractEndpointResponse<Paths, T>>;
141
202
 
142
203
  export interface FetcherOptions {
143
204
  baseURL?: string;