@geekmidas/client 0.0.1 → 0.1.1

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
@@ -1,9 +1,18 @@
1
1
  export type OpenAPIRoutes<Paths> = keyof Paths;
2
2
 
3
- export type ExtractMethod<
4
- Paths,
5
- Route extends OpenAPIRoutes<Paths>,
6
- > = keyof Paths[Route];
3
+ type HttpMethod =
4
+ | 'get'
5
+ | 'post'
6
+ | 'put'
7
+ | 'patch'
8
+ | 'delete'
9
+ | 'head'
10
+ | 'options';
11
+
12
+ export type ExtractMethod<Paths, Route extends OpenAPIRoutes<Paths>> = Extract<
13
+ keyof Paths[Route],
14
+ HttpMethod
15
+ >;
7
16
 
8
17
  export type ExtractPathParams<
9
18
  Paths,
@@ -59,21 +68,14 @@ export type RequestConfig<
59
68
 
60
69
  export type EndpointString = `${Uppercase<string>} ${string}`;
61
70
 
62
- // Filter out methods that are never to avoid showing them in autocomplete
63
- // This handles both explicit never and optional never (?: never)
64
- type FilterNeverMethods<T> = {
65
- [K in keyof T as T[K] extends never | undefined
66
- ? never
67
- : K]: T[K] extends undefined ? never : T[K];
68
- };
69
-
70
71
  // Generate all valid endpoint strings from OpenAPI paths
72
+ // Uses Extract with HttpMethod to filter out non-HTTP keys like 'parameters'
71
73
  export type ValidEndpoint<Paths> = {
72
74
  [Route in keyof Paths]: {
73
- [Method in keyof FilterNeverMethods<Paths[Route]>]: `${Uppercase<
75
+ [Method in Extract<keyof Paths[Route], HttpMethod>]: `${Uppercase<
74
76
  string & Method
75
77
  >} ${string & Route}`;
76
- }[keyof FilterNeverMethods<Paths[Route]>];
78
+ }[Extract<keyof Paths[Route], HttpMethod>];
77
79
  }[keyof Paths];
78
80
 
79
81
  // Helper type to get autocomplete for endpoint strings
@@ -130,14 +132,75 @@ export type ExtractEndpointConfig<
130
132
  : never
131
133
  : never;
132
134
 
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
- };
135
+ /**
136
+ * Build a request config type where:
137
+ * - `params` is required if the endpoint has path parameters
138
+ * - `body` is required if the endpoint has a request body
139
+ * - `query` and `headers` are always optional
140
+ */
141
+ export type FilteredRequestConfig<
142
+ Paths,
143
+ T extends EndpointString,
144
+ > = T extends `${infer Method} ${infer Route}`
145
+ ? Route extends OpenAPIRoutes<Paths>
146
+ ? Lowercase<Method> extends ExtractMethod<Paths, Route>
147
+ ? BuildRequestConfig<
148
+ ExtractPathParams<Paths, Route>,
149
+ ExtractQueryParams<Paths, Route, Lowercase<Method>>,
150
+ ExtractRequestBody<Paths, Route, Lowercase<Method>>
151
+ >
152
+ : never
153
+ : never
154
+ : never;
155
+
156
+ /**
157
+ * Helper to build request config with correct required/optional fields
158
+ */
159
+ type BuildRequestConfig<TParams, TQuery, TBody> = SimplifyIntersection<
160
+ // params: required if not never
161
+ (TParams extends never ? {} : { params: TParams }) &
162
+ // body: required if not never
163
+ (TBody extends never ? {} : { body: TBody }) &
164
+ // query: optional if not never
165
+ (TQuery extends never ? {} : { query?: TQuery }) & {
166
+ // headers: always optional
167
+ headers?: Record<string, string>;
168
+ }
169
+ >;
170
+
171
+ /**
172
+ * Simplify intersection types for better IDE display
173
+ */
174
+ type SimplifyIntersection<T> = { [K in keyof T]: T[K] };
175
+
176
+ /**
177
+ * Check if the config object is required (has any required fields)
178
+ */
179
+ export type IsConfigRequired<
180
+ Paths,
181
+ T extends EndpointString,
182
+ > = T extends `${infer Method} ${infer Route}`
183
+ ? Route extends OpenAPIRoutes<Paths>
184
+ ? Lowercase<Method> extends ExtractMethod<Paths, Route>
185
+ ? ExtractPathParams<Paths, Route> extends never
186
+ ? ExtractRequestBody<Paths, Route, Lowercase<Method>> extends never
187
+ ? false
188
+ : true
189
+ : true
190
+ : false
191
+ : false
192
+ : false;
193
+
194
+ /**
195
+ * Typed function signature for the API client.
196
+ * Config is required when endpoint has path params or body.
197
+ */
198
+ export type TypedApiFunction<Paths> = <T extends TypedEndpoint<Paths>>(
199
+ endpoint: T,
200
+ ...args: IsConfigRequired<Paths, T> extends true
201
+ ? [config: FilteredRequestConfig<Paths, T>]
202
+ : [config?: FilteredRequestConfig<Paths, T>]
203
+ ) => Promise<ExtractEndpointResponse<Paths, T>>;
141
204
 
142
205
  export interface FetcherOptions {
143
206
  baseURL?: string;