@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,188 @@
1
+ import { e } from '@geekmidas/constructs/endpoints';
2
+ import { describe, expectTypeOf, it } from 'vitest';
3
+ import { z } from 'zod';
4
+ import type { InferOpenApi } from '../infer';
5
+
6
+ describe('InferOpenApi - TypedFetcher Integration', () => {
7
+ it('should generate paths structure compatible with TypedFetcher', () => {
8
+ // Define some endpoints
9
+ const getUserEndpoint = e
10
+ .get('/users/{id}')
11
+ .params(z.object({ id: z.string() }))
12
+ .output(z.object({ id: z.string(), name: z.string(), email: z.string() }))
13
+ .handle(async ({ params }) => ({
14
+ id: params.id,
15
+ name: 'John Doe',
16
+ email: 'john@example.com',
17
+ }));
18
+
19
+ const createUserEndpoint = e
20
+ .post('/users')
21
+ .body(z.object({ name: z.string(), email: z.string() }))
22
+ .output(z.object({ id: z.string(), name: z.string(), email: z.string() }))
23
+ .handle(async ({ body }) => ({
24
+ id: '123',
25
+ name: body.name,
26
+ email: body.email,
27
+ }));
28
+
29
+ const listUsersEndpoint = e
30
+ .get('/users')
31
+ .query(z.object({ page: z.coerce.number().optional() }))
32
+ .output(
33
+ z.object({
34
+ users: z.array(
35
+ z.object({ id: z.string(), name: z.string(), email: z.string() }),
36
+ ),
37
+ }),
38
+ )
39
+ .handle(async ({ query }) => ({
40
+ users: [],
41
+ }));
42
+
43
+ // Infer paths from endpoints
44
+ const endpoints = [
45
+ getUserEndpoint,
46
+ createUserEndpoint,
47
+ listUsersEndpoint,
48
+ ] as const;
49
+ type Paths = InferOpenApi<typeof endpoints>['paths'];
50
+
51
+ // Verify the structure matches TypedFetcher expectations
52
+ expectTypeOf<Paths>().toMatchTypeOf<{
53
+ '/users/{id}': {
54
+ get: {
55
+ parameters: {
56
+ path: { id: string };
57
+ };
58
+ responses: {
59
+ 200: {
60
+ content: {
61
+ 'application/json': {
62
+ id: string;
63
+ name: string;
64
+ email: string;
65
+ };
66
+ };
67
+ };
68
+ };
69
+ };
70
+ };
71
+ '/users': {
72
+ get: {
73
+ parameters: {
74
+ query: { page?: number };
75
+ };
76
+ responses: {
77
+ 200: {
78
+ content: {
79
+ 'application/json': {
80
+ users: Array<{ id: string; name: string; email: string }>;
81
+ };
82
+ };
83
+ };
84
+ };
85
+ };
86
+ post: {
87
+ requestBody: {
88
+ content: {
89
+ 'application/json': {
90
+ name: string;
91
+ email: string;
92
+ };
93
+ };
94
+ };
95
+ responses: {
96
+ 200: {
97
+ content: {
98
+ 'application/json': {
99
+ id: string;
100
+ name: string;
101
+ email: string;
102
+ };
103
+ };
104
+ };
105
+ };
106
+ };
107
+ };
108
+ }>();
109
+ });
110
+
111
+ it('should handle endpoints with multiple parameter types', () => {
112
+ const updateUserEndpoint = e
113
+ .put('/users/{id}')
114
+ .params(z.object({ id: z.string() }))
115
+ .query(z.object({ notify: z.coerce.boolean().optional() }))
116
+ .body(z.object({ name: z.string() }))
117
+ .output(z.object({ id: z.string(), name: z.string() }))
118
+ .handle(async ({ params, query, body }) => ({
119
+ id: params.id,
120
+ name: body.name,
121
+ }));
122
+
123
+ type Paths = InferOpenApi<[typeof updateUserEndpoint]>['paths'];
124
+
125
+ // Verify all parameter types are present
126
+ expectTypeOf<Paths>().toMatchTypeOf<{
127
+ '/users/{id}': {
128
+ parameters: {
129
+ path: { id: string };
130
+ query: { notify?: boolean };
131
+ };
132
+ put: {
133
+ parameters: {
134
+ path: { id: string };
135
+ query: { notify?: boolean };
136
+ };
137
+ requestBody: {
138
+ content: {
139
+ 'application/json': {
140
+ name: string;
141
+ };
142
+ };
143
+ };
144
+ responses: {
145
+ 200: {
146
+ content: {
147
+ 'application/json': {
148
+ id: string;
149
+ name: string;
150
+ };
151
+ };
152
+ };
153
+ };
154
+ };
155
+ };
156
+ }>();
157
+ });
158
+
159
+ it('should handle endpoints without output schema', () => {
160
+ const deleteUserEndpoint = e
161
+ .delete('/users/{id}')
162
+ .params(z.object({ id: z.string() }))
163
+ .handle(async ({ params }) => {
164
+ // No return value
165
+ });
166
+
167
+ type Paths = InferOpenApi<[typeof deleteUserEndpoint]>['paths'];
168
+
169
+ // Verify response content is never when no output schema
170
+ expectTypeOf<Paths>().toMatchTypeOf<{
171
+ '/users/{id}': {
172
+ parameters: {
173
+ path: { id: string };
174
+ };
175
+ delete: {
176
+ parameters: {
177
+ path: { id: string };
178
+ };
179
+ responses: {
180
+ 200: {
181
+ content: never;
182
+ };
183
+ };
184
+ };
185
+ };
186
+ }>();
187
+ });
188
+ });
@@ -0,0 +1,189 @@
1
+ import { e } from '@geekmidas/constructs/endpoints';
2
+ import { describe, expectTypeOf, it } from 'vitest';
3
+ import { z } from 'zod';
4
+ import type { InferOpenApi, InferOpenApiFromEndpoint } from '../infer';
5
+
6
+ describe('InferOpenApi', () => {
7
+ describe('single endpoint', () => {
8
+ it('should infer OpenAPI structure for GET endpoint', () => {
9
+ const endpoint = e
10
+ .get('/users/{id}')
11
+ .params(z.object({ id: z.string() }))
12
+ .output(z.object({ id: z.string(), name: z.string() }))
13
+ .handle(async ({ params }) => ({
14
+ id: params.id,
15
+ name: 'John',
16
+ }));
17
+
18
+ type API = InferOpenApiFromEndpoint<typeof endpoint>;
19
+
20
+ expectTypeOf<API>().toMatchTypeOf<{
21
+ paths: {
22
+ '/users/{id}': {
23
+ get: {
24
+ responses: {
25
+ '200': {
26
+ description: string;
27
+ };
28
+ };
29
+ };
30
+ };
31
+ };
32
+ }>();
33
+ });
34
+
35
+ it('should infer OpenAPI structure for POST endpoint with body', () => {
36
+ const endpoint = e
37
+ .post('/users')
38
+ .body(z.object({ name: z.string(), email: z.string() }))
39
+ .output(z.object({ id: z.string(), name: z.string() }))
40
+ .handle(async ({ body }) => ({
41
+ id: '123',
42
+ name: body.name,
43
+ }));
44
+
45
+ type API = InferOpenApiFromEndpoint<typeof endpoint>;
46
+
47
+ expectTypeOf<API>().toMatchTypeOf<{
48
+ paths: {
49
+ '/users': {
50
+ post: {
51
+ responses: {
52
+ '200': {
53
+ description: string;
54
+ };
55
+ };
56
+ };
57
+ };
58
+ };
59
+ }>();
60
+ });
61
+ });
62
+
63
+ describe('multiple endpoints', () => {
64
+ it('should merge OpenAPI specs from multiple endpoints', () => {
65
+ const getUserEndpoint = e
66
+ .get('/users/{id}')
67
+ .params(z.object({ id: z.string() }))
68
+ .output(z.object({ id: z.string(), name: z.string() }))
69
+ .handle(async ({ params }) => ({
70
+ id: params.id,
71
+ name: 'John',
72
+ }));
73
+
74
+ const createUserEndpoint = e
75
+ .post('/users')
76
+ .body(z.object({ name: z.string() }))
77
+ .output(z.object({ id: z.string(), name: z.string() }))
78
+ .handle(async ({ body }) => ({
79
+ id: '123',
80
+ name: body.name,
81
+ }));
82
+
83
+ const endpoints = [getUserEndpoint, createUserEndpoint] as const;
84
+
85
+ type API = InferOpenApi<typeof endpoints>;
86
+
87
+ // Should have both paths
88
+ expectTypeOf<API>().toMatchTypeOf<{
89
+ paths: {
90
+ '/users/{id}': {
91
+ get: unknown;
92
+ };
93
+ '/users': {
94
+ post: unknown;
95
+ };
96
+ };
97
+ }>();
98
+ });
99
+
100
+ it('should handle endpoints with different HTTP methods on same path', () => {
101
+ const getUserEndpoint = e
102
+ .get('/users')
103
+ .output(z.object({ users: z.array(z.object({ id: z.string() })) }))
104
+ .handle(async () => ({
105
+ users: [],
106
+ }));
107
+
108
+ const createUserEndpoint = e
109
+ .post('/users')
110
+ .body(z.object({ name: z.string() }))
111
+ .output(z.object({ id: z.string() }))
112
+ .handle(async ({ body }) => ({
113
+ id: '123',
114
+ }));
115
+
116
+ const endpoints = [getUserEndpoint, createUserEndpoint] as const;
117
+
118
+ type API = InferOpenApi<typeof endpoints>;
119
+
120
+ // Should have both methods on same path
121
+ expectTypeOf<API>().toMatchTypeOf<{
122
+ paths: {
123
+ '/users': {
124
+ get: unknown;
125
+ post: unknown;
126
+ };
127
+ };
128
+ }>();
129
+ });
130
+ });
131
+
132
+ describe('complex schemas', () => {
133
+ it('should handle endpoints with query parameters', () => {
134
+ const endpoint = e
135
+ .get('/users')
136
+ .query(
137
+ z.object({
138
+ page: z.coerce.number().optional(),
139
+ limit: z.coerce.number().optional(),
140
+ }),
141
+ )
142
+ .output(z.object({ users: z.array(z.object({ id: z.string() })) }))
143
+ .handle(async ({ query }) => ({
144
+ users: [],
145
+ }));
146
+
147
+ type API = InferOpenApiFromEndpoint<typeof endpoint>;
148
+
149
+ expectTypeOf<API>().toMatchTypeOf<{
150
+ paths: {
151
+ '/users': {
152
+ get: {
153
+ responses: {
154
+ '200': unknown;
155
+ };
156
+ };
157
+ };
158
+ };
159
+ }>();
160
+ });
161
+
162
+ it('should handle endpoints with body, params, and query', () => {
163
+ const endpoint = e
164
+ .put('/users/{id}')
165
+ .params(z.object({ id: z.string() }))
166
+ .query(z.object({ notify: z.coerce.boolean().optional() }))
167
+ .body(z.object({ name: z.string() }))
168
+ .output(z.object({ id: z.string(), name: z.string() }))
169
+ .handle(async ({ params, body }) => ({
170
+ id: params.id,
171
+ name: body.name,
172
+ }));
173
+
174
+ type API = InferOpenApiFromEndpoint<typeof endpoint>;
175
+
176
+ expectTypeOf<API>().toMatchTypeOf<{
177
+ paths: {
178
+ '/users/{id}': {
179
+ put: {
180
+ responses: {
181
+ '200': unknown;
182
+ };
183
+ };
184
+ };
185
+ };
186
+ }>();
187
+ });
188
+ });
189
+ });
@@ -0,0 +1,288 @@
1
+ import { TypedFetcher } from './fetcher';
2
+ import type {
3
+ ExtractEndpointResponse,
4
+ FetcherOptions,
5
+ FilteredRequestConfig,
6
+ TypedApiFunction,
7
+ TypedEndpoint,
8
+ } from './types';
9
+
10
+ /**
11
+ * Security scheme object matching OpenAPI 3.1 specification.
12
+ */
13
+ export interface SecuritySchemeObject {
14
+ type: 'apiKey' | 'http' | 'mutualTLS' | 'oauth2' | 'openIdConnect';
15
+ description?: string;
16
+ name?: string;
17
+ in?: 'query' | 'header' | 'cookie';
18
+ scheme?: string;
19
+ bearerFormat?: string;
20
+ flows?: Record<string, unknown>;
21
+ openIdConnectUrl?: string;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ /**
26
+ * Extract all non-null security scheme IDs that are actually used in the API.
27
+ * This gives us the union of scheme names that endpoints require.
28
+ */
29
+ export type UsedSecuritySchemes<
30
+ EndpointAuth extends Record<string, string | null>,
31
+ > = NonNullable<EndpointAuth[keyof EndpointAuth]>;
32
+
33
+ /**
34
+ * Interface for token storage and retrieval.
35
+ * Compatible with @geekmidas/auth TokenClient.
36
+ */
37
+ export interface TokenProvider {
38
+ /**
39
+ * Get a valid access token, refreshing if necessary.
40
+ */
41
+ getValidAccessToken(): Promise<string | null>;
42
+
43
+ /**
44
+ * Create Authorization headers from the current token.
45
+ */
46
+ createValidAuthHeaders(): Promise<Record<string, string>>;
47
+ }
48
+
49
+ /**
50
+ * Interface for API key providers.
51
+ */
52
+ export interface ApiKeyProvider {
53
+ /**
54
+ * Get the API key value.
55
+ */
56
+ getApiKey(): Promise<string> | string;
57
+ }
58
+
59
+ /**
60
+ * Interface for AWS SigV4 request signing.
61
+ */
62
+ export interface AwsSigner {
63
+ /**
64
+ * Sign a request with AWS SigV4.
65
+ * @param url - The request URL
66
+ * @param init - The request init object
67
+ * @returns Headers to add to the request
68
+ */
69
+ sign(url: string, init: RequestInit): Promise<Record<string, string>>;
70
+ }
71
+
72
+ /**
73
+ * Auth strategy configuration for a specific security scheme type.
74
+ */
75
+ export type AuthStrategy =
76
+ | { type: 'bearer'; tokenProvider: TokenProvider }
77
+ | { type: 'apiKey'; apiKeyProvider: ApiKeyProvider; headerName?: string }
78
+ | { type: 'iam'; signer: AwsSigner }
79
+ | { type: 'none' };
80
+
81
+ /**
82
+ * Options for creating an auth-aware fetcher.
83
+ *
84
+ * @template EndpointAuth - Map of endpoint strings to their auth scheme (or null for public)
85
+ * @template SecuritySchemes - Available security scheme definitions
86
+ */
87
+ export interface AuthFetcherOptions<
88
+ EndpointAuth extends Record<string, string | null>,
89
+ SecuritySchemes extends Record<string, SecuritySchemeObject>,
90
+ > extends Omit<FetcherOptions, 'onRequest'> {
91
+ /**
92
+ * Runtime map of endpoints to their required auth scheme.
93
+ * Generated by `gkm openapi --ts`.
94
+ */
95
+ endpointAuth: EndpointAuth;
96
+
97
+ /**
98
+ * Security scheme definitions.
99
+ * Generated by `gkm openapi --ts`.
100
+ */
101
+ securitySchemes: SecuritySchemes;
102
+
103
+ /**
104
+ * Auth strategies for security schemes that are actually used.
105
+ * Only schemes referenced in endpointAuth are required.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * // If endpointAuth has: { 'GET /users': 'jwt', 'POST /data': 'iam', 'GET /public': null }
110
+ * // Then authStrategies must include strategies for 'jwt' and 'iam'
111
+ * authStrategies: {
112
+ * jwt: { type: 'bearer', tokenProvider },
113
+ * iam: { type: 'iam', signer: awsSigner },
114
+ * }
115
+ * ```
116
+ */
117
+ authStrategies: Record<UsedSecuritySchemes<EndpointAuth>, AuthStrategy>;
118
+
119
+ /**
120
+ * Optional request interceptor (runs after auth headers are added).
121
+ */
122
+ onRequest?: (config: RequestInit) => RequestInit | Promise<RequestInit>;
123
+ }
124
+
125
+ /**
126
+ * Creates an auth-aware fetcher that automatically applies the correct
127
+ * authentication based on the endpoint being called.
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * import { endpointAuth, securitySchemes, paths } from './openapi';
132
+ * import { TokenClient } from '@geekmidas/auth/client';
133
+ *
134
+ * const tokenClient = new TokenClient({ ... });
135
+ *
136
+ * const api = createAuthAwareFetcher<paths>({
137
+ * baseURL: 'https://api.example.com',
138
+ * endpointAuth,
139
+ * securitySchemes,
140
+ * authStrategies: {
141
+ * bearer: { type: 'bearer', tokenProvider: tokenClient },
142
+ * iam: { type: 'iam', signer: awsSigner },
143
+ * },
144
+ * });
145
+ *
146
+ * // Bearer auth automatically applied
147
+ * const user = await api('GET /users/{id}', { params: { id: '123' } });
148
+ *
149
+ * // IAM SigV4 auth automatically applied
150
+ * const tenant = await api('POST /tenants', { body: { name: 'Acme' } });
151
+ * ```
152
+ */
153
+ export function createAuthAwareFetcher<
154
+ Paths,
155
+ EndpointAuth extends Record<string, string | null> = Record<
156
+ string,
157
+ string | null
158
+ >,
159
+ SecuritySchemes extends Record<string, SecuritySchemeObject> = Record<
160
+ string,
161
+ SecuritySchemeObject
162
+ >,
163
+ >(
164
+ options: AuthFetcherOptions<EndpointAuth, SecuritySchemes> & {
165
+ baseURL: string;
166
+ },
167
+ ): TypedApiFunction<Paths> {
168
+ const {
169
+ endpointAuth,
170
+ securitySchemes,
171
+ authStrategies,
172
+ onRequest: userOnRequest,
173
+ ...fetcherOptions
174
+ } = options;
175
+
176
+ // Create base fetcher with user's onRequest if provided
177
+ const baseFetcher = new TypedFetcher<Paths>({
178
+ ...fetcherOptions,
179
+ onRequest: userOnRequest,
180
+ });
181
+
182
+ const fetcher = async <T extends TypedEndpoint<Paths>>(
183
+ endpoint: T,
184
+ config?: FilteredRequestConfig<Paths, T>,
185
+ ): Promise<ExtractEndpointResponse<Paths, T>> => {
186
+ // Look up auth requirement for this endpoint
187
+ const schemeName = endpointAuth[endpoint as keyof EndpointAuth] as
188
+ | string
189
+ | null;
190
+
191
+ let authHeaders: Record<string, string> = {};
192
+
193
+ if (schemeName) {
194
+ const scheme = securitySchemes[schemeName as keyof SecuritySchemes];
195
+ // Since authStrategies is now required to have all used schemes,
196
+ // we can safely access it - TypeScript ensures the strategy exists
197
+ const strategy =
198
+ authStrategies[schemeName as UsedSecuritySchemes<EndpointAuth>];
199
+
200
+ if (strategy) {
201
+ authHeaders = await resolveAuthHeaders(strategy, scheme);
202
+ }
203
+ }
204
+
205
+ // Merge auth headers with config headers
206
+ const existingHeaders =
207
+ config && 'headers' in config && config.headers
208
+ ? (config.headers as Record<string, string>)
209
+ : {};
210
+
211
+ const mergedConfig = {
212
+ ...config,
213
+ headers: {
214
+ ...authHeaders,
215
+ ...existingHeaders,
216
+ },
217
+ } as unknown as FilteredRequestConfig<Paths, T>;
218
+
219
+ return baseFetcher.request(endpoint, mergedConfig);
220
+ };
221
+
222
+ return fetcher as TypedApiFunction<Paths>;
223
+ }
224
+
225
+ /**
226
+ * Resolves auth headers based on the strategy and scheme.
227
+ */
228
+ async function resolveAuthHeaders(
229
+ strategy: AuthStrategy,
230
+ scheme: SecuritySchemeObject,
231
+ ): Promise<Record<string, string>> {
232
+ switch (strategy.type) {
233
+ case 'bearer': {
234
+ return strategy.tokenProvider.createValidAuthHeaders();
235
+ }
236
+
237
+ case 'apiKey': {
238
+ const apiKey = await strategy.apiKeyProvider.getApiKey();
239
+ const headerName = strategy.headerName || scheme.name || 'X-API-Key';
240
+
241
+ if (scheme.in === 'header' || !scheme.in) {
242
+ return { [headerName]: apiKey };
243
+ }
244
+ // Note: query and cookie API keys are handled differently
245
+ // For now, we only support header-based API keys
246
+ return {};
247
+ }
248
+
249
+ case 'iam': {
250
+ // IAM signing requires the full URL and request config
251
+ // This is a simplified version - full implementation would need
252
+ // access to the complete request
253
+ // For now, return empty - the actual signing should be done
254
+ // in a custom onRequest interceptor if needed
255
+ return {};
256
+ }
257
+
258
+ case 'none':
259
+ default:
260
+ return {};
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Type helper to extract the security scheme ID from an endpoint.
266
+ */
267
+ export type GetEndpointAuth<
268
+ EndpointAuth extends Record<string, string | null>,
269
+ Endpoint extends keyof EndpointAuth,
270
+ > = EndpointAuth[Endpoint];
271
+
272
+ /**
273
+ * Type helper to get all authenticated endpoints.
274
+ */
275
+ export type AuthenticatedEndpoints<
276
+ EndpointAuth extends Record<string, string | null>,
277
+ > = {
278
+ [K in keyof EndpointAuth]: EndpointAuth[K] extends null ? never : K;
279
+ }[keyof EndpointAuth];
280
+
281
+ /**
282
+ * Type helper to get all public endpoints.
283
+ */
284
+ export type PublicEndpoints<
285
+ EndpointAuth extends Record<string, string | null>,
286
+ > = {
287
+ [K in keyof EndpointAuth]: EndpointAuth[K] extends null ? K : never;
288
+ }[keyof EndpointAuth];