@hypequery/serve 0.0.2 → 0.0.5
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/dist/builder.d.ts +3 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +41 -0
- package/dist/pipeline.d.ts +72 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +317 -0
- package/dist/query-logger.d.ts +65 -0
- package/dist/query-logger.d.ts.map +1 -0
- package/dist/query-logger.js +91 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -406
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +16 -0
- package/package.json +2 -2
- package/dist/queries.d.ts +0 -3
- package/dist/queries.d.ts.map +0 -1
- package/dist/queries.js +0 -1
- package/dist/query.d.ts +0 -4
- package/dist/query.d.ts.map +0 -1
- package/dist/query.js +0 -1
- package/dist/sdk-generator.d.ts +0 -7
- package/dist/sdk-generator.d.ts.map +0 -1
- package/dist/sdk-generator.js +0 -143
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,WAAW,EAEX,qBAAqB,EAStB,MAAM,YAAY,CAAC;AAuBpB,eAAO,MAAM,sBAAsB,GACjC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,SAAS,WAAW,OACtB,qBAAqB,CAAC,QAAQ,EAAE,KAAK,CA6CzC,CAAC"}
|
package/dist/builder.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mergeTags } from './utils.js';
|
|
2
|
+
const defaultState = () => ({
|
|
3
|
+
tags: [],
|
|
4
|
+
middlewares: [],
|
|
5
|
+
});
|
|
6
|
+
export const createProcedureBuilder = () => {
|
|
7
|
+
const build = (state) => ({
|
|
8
|
+
input: (schema) => build({ ...state, inputSchema: schema }),
|
|
9
|
+
output: (schema) => build({ ...state, outputSchema: schema }),
|
|
10
|
+
describe: (description) => build({ ...state, description }),
|
|
11
|
+
name: (name) => build({ ...state, name }),
|
|
12
|
+
summary: (summary) => build({ ...state, summary }),
|
|
13
|
+
tag: (tag) => build({ ...state, tags: mergeTags(state.tags, [tag]) }),
|
|
14
|
+
tags: (tags) => build({ ...state, tags: mergeTags(state.tags, tags) }),
|
|
15
|
+
method: (method) => build({ ...state, method }),
|
|
16
|
+
cache: (ttlMs) => build({ ...state, cacheTtlMs: ttlMs }),
|
|
17
|
+
auth: (strategy) => build({ ...state, auth: strategy }),
|
|
18
|
+
tenant: (config) => build({ ...state, tenant: config }),
|
|
19
|
+
custom: (custom) => build({ ...state, custom: { ...(state.custom ?? {}), ...custom } }),
|
|
20
|
+
use: (...middlewares) => build({ ...state, middlewares: [...state.middlewares, ...middlewares] }),
|
|
21
|
+
query: (executable) => {
|
|
22
|
+
const config = {
|
|
23
|
+
description: state.description,
|
|
24
|
+
name: state.name,
|
|
25
|
+
summary: state.summary,
|
|
26
|
+
tags: state.tags,
|
|
27
|
+
method: state.method,
|
|
28
|
+
inputSchema: state.inputSchema,
|
|
29
|
+
outputSchema: state.outputSchema,
|
|
30
|
+
cacheTtlMs: state.cacheTtlMs,
|
|
31
|
+
auth: typeof state.auth === 'undefined' ? null : state.auth,
|
|
32
|
+
tenant: state.tenant,
|
|
33
|
+
custom: state.custom,
|
|
34
|
+
middlewares: state.middlewares,
|
|
35
|
+
query: executable,
|
|
36
|
+
};
|
|
37
|
+
return config;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return build(defaultState());
|
|
41
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { AuthContext, AuthStrategy, DocsOptions, OpenApiOptions, ServeContextFactory, ServeEndpoint, ServeHandler, ServeLifecycleHooks, ServeMiddleware, ServeRequest, ServeResponse, TenantConfig } from './types.js';
|
|
3
|
+
export interface ExecuteEndpointOptions<TContext extends Record<string, unknown>, TAuth extends AuthContext> {
|
|
4
|
+
endpoint: ServeEndpoint<any, any, TContext, TAuth>;
|
|
5
|
+
request: ServeRequest;
|
|
6
|
+
requestId?: string;
|
|
7
|
+
authStrategies: AuthStrategy<TAuth>[];
|
|
8
|
+
contextFactory?: ServeContextFactory<TContext, TAuth>;
|
|
9
|
+
globalMiddlewares: ServeMiddleware<any, any, TContext, TAuth>[];
|
|
10
|
+
tenantConfig?: TenantConfig<TAuth>;
|
|
11
|
+
hooks?: ServeLifecycleHooks<TAuth>;
|
|
12
|
+
additionalContext?: Partial<TContext>;
|
|
13
|
+
}
|
|
14
|
+
export declare const executeEndpoint: <TContext extends Record<string, unknown>, TAuth extends AuthContext>(options: ExecuteEndpointOptions<TContext, TAuth>) => Promise<ServeResponse>;
|
|
15
|
+
interface HandlerOptions<TContext extends Record<string, unknown>, TAuth extends AuthContext> {
|
|
16
|
+
router: import('./router.js').ServeRouter;
|
|
17
|
+
globalMiddlewares: ServeMiddleware<any, any, TContext, TAuth>[];
|
|
18
|
+
authStrategies: AuthStrategy<TAuth>[];
|
|
19
|
+
tenantConfig?: TenantConfig<TAuth>;
|
|
20
|
+
contextFactory?: ServeContextFactory<TContext, TAuth>;
|
|
21
|
+
hooks?: ServeLifecycleHooks<TAuth>;
|
|
22
|
+
}
|
|
23
|
+
export declare const createServeHandler: <TContext extends Record<string, unknown>, TAuth extends AuthContext>({ router, globalMiddlewares, authStrategies, tenantConfig, contextFactory, hooks, }: HandlerOptions<TContext, TAuth>) => ServeHandler;
|
|
24
|
+
export declare const createOpenApiEndpoint: (path: string, getEndpoints: () => ServeEndpoint<any, any, any, any>[], options?: OpenApiOptions) => {
|
|
25
|
+
key: string;
|
|
26
|
+
method: "GET";
|
|
27
|
+
inputSchema: undefined;
|
|
28
|
+
outputSchema: z.ZodAny;
|
|
29
|
+
handler: () => Promise<unknown>;
|
|
30
|
+
query: undefined;
|
|
31
|
+
middlewares: never[];
|
|
32
|
+
auth: null;
|
|
33
|
+
metadata: {
|
|
34
|
+
path: string;
|
|
35
|
+
method: "GET";
|
|
36
|
+
name: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
description: string;
|
|
39
|
+
tags: string[];
|
|
40
|
+
requiresAuth: false;
|
|
41
|
+
deprecated: false;
|
|
42
|
+
visibility: "internal";
|
|
43
|
+
};
|
|
44
|
+
cacheTtlMs: null;
|
|
45
|
+
};
|
|
46
|
+
export declare const createDocsEndpoint: (path: string, openapiPath: string, options?: DocsOptions) => {
|
|
47
|
+
key: string;
|
|
48
|
+
method: "GET";
|
|
49
|
+
inputSchema: undefined;
|
|
50
|
+
outputSchema: z.ZodString;
|
|
51
|
+
handler: () => Promise<string>;
|
|
52
|
+
query: undefined;
|
|
53
|
+
middlewares: never[];
|
|
54
|
+
auth: null;
|
|
55
|
+
metadata: {
|
|
56
|
+
path: string;
|
|
57
|
+
method: "GET";
|
|
58
|
+
name: string;
|
|
59
|
+
summary: string;
|
|
60
|
+
description: string;
|
|
61
|
+
tags: string[];
|
|
62
|
+
requiresAuth: false;
|
|
63
|
+
deprecated: false;
|
|
64
|
+
visibility: "internal";
|
|
65
|
+
};
|
|
66
|
+
cacheTtlMs: null;
|
|
67
|
+
defaultHeaders: {
|
|
68
|
+
'content-type': string;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
export {};
|
|
72
|
+
//# sourceMappingURL=pipeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAC;AAEzC,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,WAAW,EAKX,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,YAAY,EACZ,aAAa,EACb,YAAY,EAEb,MAAM,YAAY,CAAC;AAiIpB,MAAM,WAAW,sBAAsB,CACrC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,SAAS,WAAW;IAEzB,QAAQ,EAAE,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;IACnD,OAAO,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;IACtC,cAAc,CAAC,EAAE,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACtD,iBAAiB,EAAE,eAAe,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;IAChE,YAAY,CAAC,EAAE,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,KAAK,CAAC,EAAE,mBAAmB,CAAC,KAAK,CAAC,CAAC;IACnC,iBAAiB,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;CACvC;AAED,eAAO,MAAM,eAAe,GAC1B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,SAAS,WAAW,EAEzB,SAAS,sBAAsB,CAAC,QAAQ,EAAE,KAAK,CAAC,KAC/C,OAAO,CAAC,aAAa,CA0LvB,CAAC;AAEF,UAAU,cAAc,CACtB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,SAAS,WAAW;IAEzB,MAAM,EAAE,OAAO,aAAa,EAAE,WAAW,CAAC;IAC1C,iBAAiB,EAAE,eAAe,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;IAChE,cAAc,EAAE,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;IACtC,YAAY,CAAC,EAAE,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,cAAc,CAAC,EAAE,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACtD,KAAK,CAAC,EAAE,mBAAmB,CAAC,KAAK,CAAC,CAAC;CACpC;AAED,eAAO,MAAM,kBAAkB,GAC7B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,SAAS,WAAW,EACzB,qFAOC,cAAc,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAG,YAqBpC,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,MAAM,MAAM,EACZ,cAAc,MAAM,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,EACvD,UAAU,cAAc;;;;;;;;;;;;;;;;;;;;;CA8BzB,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAC7B,MAAM,MAAM,EACZ,aAAa,MAAM,EACnB,UAAU,WAAW;;;;;;;;;;;;;;;;;;;;;;;;CAyBmD,CAAC"}
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTenantScope, warnTenantMisconfiguration } from './tenant.js';
|
|
3
|
+
import { generateRequestId } from './utils.js';
|
|
4
|
+
import { buildOpenApiDocument } from './openapi.js';
|
|
5
|
+
import { buildDocsHtml } from './docs-ui.js';
|
|
6
|
+
const safeInvokeHook = async (name, hook, payload) => {
|
|
7
|
+
if (!hook)
|
|
8
|
+
return;
|
|
9
|
+
try {
|
|
10
|
+
await hook(payload);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
console.error(`[hypequery/serve] ${name} hook failed`, error);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const createErrorResponse = (status, type, message, details) => ({
|
|
17
|
+
status,
|
|
18
|
+
body: { error: { type, message, ...(details ? { details } : {}) } },
|
|
19
|
+
});
|
|
20
|
+
const buildContextInput = (request) => {
|
|
21
|
+
if (request.body !== undefined && request.body !== null) {
|
|
22
|
+
return request.body;
|
|
23
|
+
}
|
|
24
|
+
if (request.query && Object.keys(request.query).length > 0) {
|
|
25
|
+
return request.query;
|
|
26
|
+
}
|
|
27
|
+
return {};
|
|
28
|
+
};
|
|
29
|
+
const runMiddlewares = async (middlewares, ctx, handler) => {
|
|
30
|
+
let current = handler;
|
|
31
|
+
for (let i = middlewares.length - 1; i >= 0; i -= 1) {
|
|
32
|
+
const middleware = middlewares[i];
|
|
33
|
+
const next = current;
|
|
34
|
+
current = () => middleware(ctx, next);
|
|
35
|
+
}
|
|
36
|
+
return current();
|
|
37
|
+
};
|
|
38
|
+
const authenticateRequest = async (strategies, request, metadata) => {
|
|
39
|
+
for (const strategy of strategies) {
|
|
40
|
+
const result = await strategy({ request, endpoint: metadata });
|
|
41
|
+
if (result) {
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
const gatherAuthStrategies = (endpointStrategy, globalStrategies) => {
|
|
48
|
+
const combined = [];
|
|
49
|
+
if (endpointStrategy)
|
|
50
|
+
combined.push(endpointStrategy);
|
|
51
|
+
combined.push(...globalStrategies);
|
|
52
|
+
return combined;
|
|
53
|
+
};
|
|
54
|
+
const computeRequiresAuth = (metadata, endpointStrategy, globalStrategies) => {
|
|
55
|
+
if (typeof metadata.requiresAuth === 'boolean') {
|
|
56
|
+
return metadata.requiresAuth;
|
|
57
|
+
}
|
|
58
|
+
if (endpointStrategy) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return globalStrategies.length > 0;
|
|
62
|
+
};
|
|
63
|
+
const validateInput = (schema, payload) => {
|
|
64
|
+
if (!schema) {
|
|
65
|
+
return { success: true, data: payload };
|
|
66
|
+
}
|
|
67
|
+
const result = schema.safeParse(payload);
|
|
68
|
+
return result.success
|
|
69
|
+
? { success: true, data: result.data }
|
|
70
|
+
: { success: false, error: result.error };
|
|
71
|
+
};
|
|
72
|
+
const cloneContext = (ctx) => (ctx ? { ...ctx } : {});
|
|
73
|
+
const resolveContext = async (factory, request, auth) => {
|
|
74
|
+
if (!factory) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
if (typeof factory === 'function') {
|
|
78
|
+
const value = await factory({ request, auth });
|
|
79
|
+
return cloneContext(value);
|
|
80
|
+
}
|
|
81
|
+
return cloneContext(factory);
|
|
82
|
+
};
|
|
83
|
+
const resolveRequestId = (request, provided) => provided ?? request.headers['x-request-id'] ?? request.headers['x-trace-id'] ?? generateRequestId();
|
|
84
|
+
export const executeEndpoint = async (options) => {
|
|
85
|
+
const { endpoint, request, requestId: explicitRequestId, authStrategies, contextFactory, globalMiddlewares, tenantConfig, hooks = {}, additionalContext, } = options;
|
|
86
|
+
const requestId = resolveRequestId(request, explicitRequestId);
|
|
87
|
+
const locals = {};
|
|
88
|
+
let cacheTtlMs = endpoint.cacheTtlMs ?? null;
|
|
89
|
+
const setCacheTtl = (ttl) => {
|
|
90
|
+
cacheTtlMs = ttl;
|
|
91
|
+
};
|
|
92
|
+
const context = {
|
|
93
|
+
request,
|
|
94
|
+
input: buildContextInput(request),
|
|
95
|
+
auth: null,
|
|
96
|
+
metadata: endpoint.metadata,
|
|
97
|
+
locals,
|
|
98
|
+
setCacheTtl,
|
|
99
|
+
};
|
|
100
|
+
const startedAt = Date.now();
|
|
101
|
+
await safeInvokeHook('onRequestStart', hooks.onRequestStart, {
|
|
102
|
+
requestId,
|
|
103
|
+
queryKey: endpoint.key,
|
|
104
|
+
metadata: endpoint.metadata,
|
|
105
|
+
request,
|
|
106
|
+
auth: context.auth,
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const endpointAuth = endpoint.auth ?? null;
|
|
110
|
+
const strategies = gatherAuthStrategies(endpointAuth, authStrategies ?? []);
|
|
111
|
+
const requiresAuth = computeRequiresAuth(endpoint.metadata, endpointAuth, authStrategies ?? []);
|
|
112
|
+
const metadataWithAuth = {
|
|
113
|
+
...endpoint.metadata,
|
|
114
|
+
requiresAuth,
|
|
115
|
+
};
|
|
116
|
+
context.metadata = metadataWithAuth;
|
|
117
|
+
const authContext = await authenticateRequest(strategies, request, metadataWithAuth);
|
|
118
|
+
if (!authContext && requiresAuth) {
|
|
119
|
+
await safeInvokeHook('onAuthFailure', hooks.onAuthFailure, {
|
|
120
|
+
requestId,
|
|
121
|
+
queryKey: endpoint.key,
|
|
122
|
+
metadata: metadataWithAuth,
|
|
123
|
+
request,
|
|
124
|
+
auth: context.auth,
|
|
125
|
+
reason: 'MISSING',
|
|
126
|
+
});
|
|
127
|
+
return createErrorResponse(401, 'UNAUTHORIZED', 'Authentication required', {
|
|
128
|
+
reason: 'missing_credentials',
|
|
129
|
+
strategies_attempted: strategies.length,
|
|
130
|
+
endpoint: endpoint.metadata.path,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
context.auth = authContext;
|
|
134
|
+
const resolvedContext = await resolveContext(contextFactory, request, authContext);
|
|
135
|
+
Object.assign(context, resolvedContext, additionalContext);
|
|
136
|
+
const activeTenantConfig = endpoint.tenant ?? tenantConfig;
|
|
137
|
+
if (activeTenantConfig) {
|
|
138
|
+
const tenantRequired = activeTenantConfig.required !== false;
|
|
139
|
+
const tenantId = authContext ? activeTenantConfig.extract(authContext) : null;
|
|
140
|
+
if (!tenantId && tenantRequired) {
|
|
141
|
+
const errorMessage = activeTenantConfig.errorMessage ??
|
|
142
|
+
'Tenant context is required but could not be determined from authentication';
|
|
143
|
+
await safeInvokeHook('onError', hooks.onError, {
|
|
144
|
+
requestId,
|
|
145
|
+
queryKey: endpoint.key,
|
|
146
|
+
metadata: metadataWithAuth,
|
|
147
|
+
request,
|
|
148
|
+
auth: context.auth,
|
|
149
|
+
durationMs: Date.now() - startedAt,
|
|
150
|
+
error: new Error(errorMessage),
|
|
151
|
+
});
|
|
152
|
+
return createErrorResponse(403, 'UNAUTHORIZED', errorMessage, {
|
|
153
|
+
reason: 'missing_tenant_context',
|
|
154
|
+
tenant_required: true,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (tenantId) {
|
|
158
|
+
context.tenantId = tenantId;
|
|
159
|
+
const mode = activeTenantConfig.mode ?? 'manual';
|
|
160
|
+
const column = activeTenantConfig.column;
|
|
161
|
+
if (mode === 'auto-inject' && column) {
|
|
162
|
+
const contextValues = context;
|
|
163
|
+
for (const key of Object.keys(contextValues)) {
|
|
164
|
+
const value = contextValues[key];
|
|
165
|
+
if (value && typeof value === 'object' && 'table' in value && typeof value.table === 'function') {
|
|
166
|
+
contextValues[key] = createTenantScope(value, {
|
|
167
|
+
tenantId,
|
|
168
|
+
column,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (mode === 'manual') {
|
|
174
|
+
warnTenantMisconfiguration({
|
|
175
|
+
queryKey: endpoint.key,
|
|
176
|
+
hasTenantConfig: true,
|
|
177
|
+
hasTenantId: true,
|
|
178
|
+
mode: 'manual',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (!tenantRequired) {
|
|
183
|
+
warnTenantMisconfiguration({
|
|
184
|
+
queryKey: endpoint.key,
|
|
185
|
+
hasTenantConfig: true,
|
|
186
|
+
hasTenantId: false,
|
|
187
|
+
mode: activeTenantConfig.mode,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const validationResult = validateInput(endpoint.inputSchema, context.input);
|
|
192
|
+
if (!validationResult.success) {
|
|
193
|
+
await safeInvokeHook('onError', hooks.onError, {
|
|
194
|
+
requestId,
|
|
195
|
+
queryKey: endpoint.key,
|
|
196
|
+
metadata: metadataWithAuth,
|
|
197
|
+
request,
|
|
198
|
+
auth: context.auth,
|
|
199
|
+
durationMs: Date.now() - startedAt,
|
|
200
|
+
error: validationResult.error,
|
|
201
|
+
});
|
|
202
|
+
return createErrorResponse(400, 'VALIDATION_ERROR', 'Request validation failed', {
|
|
203
|
+
issues: validationResult.error.issues,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
context.input = validationResult.data;
|
|
207
|
+
const pipeline = [
|
|
208
|
+
...(globalMiddlewares ?? []),
|
|
209
|
+
...endpoint.middlewares,
|
|
210
|
+
];
|
|
211
|
+
const result = await runMiddlewares(pipeline, context, () => endpoint.handler(context));
|
|
212
|
+
const headers = { ...(endpoint.defaultHeaders ?? {}) };
|
|
213
|
+
if (typeof cacheTtlMs === 'number') {
|
|
214
|
+
headers['cache-control'] = cacheTtlMs > 0 ? `public, max-age=${Math.floor(cacheTtlMs / 1000)}` : 'no-store';
|
|
215
|
+
}
|
|
216
|
+
const durationMs = Date.now() - startedAt;
|
|
217
|
+
await safeInvokeHook('onRequestEnd', hooks.onRequestEnd, {
|
|
218
|
+
requestId,
|
|
219
|
+
queryKey: endpoint.key,
|
|
220
|
+
metadata: metadataWithAuth,
|
|
221
|
+
request,
|
|
222
|
+
auth: context.auth,
|
|
223
|
+
durationMs,
|
|
224
|
+
result,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
status: 200,
|
|
228
|
+
headers,
|
|
229
|
+
body: result,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
await safeInvokeHook('onError', hooks.onError, {
|
|
234
|
+
requestId,
|
|
235
|
+
queryKey: endpoint.key,
|
|
236
|
+
metadata: context.metadata,
|
|
237
|
+
request,
|
|
238
|
+
auth: context.auth,
|
|
239
|
+
durationMs: Date.now() - startedAt,
|
|
240
|
+
error,
|
|
241
|
+
});
|
|
242
|
+
const message = error instanceof Error ? error.message : 'Unexpected error';
|
|
243
|
+
return createErrorResponse(500, 'INTERNAL_SERVER_ERROR', message);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
export const createServeHandler = ({ router, globalMiddlewares, authStrategies, tenantConfig, contextFactory, hooks, }) => {
|
|
247
|
+
return async (request) => {
|
|
248
|
+
const endpoint = router.match(request.method, request.path);
|
|
249
|
+
if (!endpoint) {
|
|
250
|
+
return createErrorResponse(404, 'NOT_FOUND', `No endpoint registered for ${request.method} ${request.path}`);
|
|
251
|
+
}
|
|
252
|
+
return executeEndpoint({
|
|
253
|
+
endpoint,
|
|
254
|
+
request,
|
|
255
|
+
authStrategies,
|
|
256
|
+
contextFactory,
|
|
257
|
+
globalMiddlewares,
|
|
258
|
+
tenantConfig,
|
|
259
|
+
hooks,
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
export const createOpenApiEndpoint = (path, getEndpoints, options) => {
|
|
264
|
+
let cachedDocument = null;
|
|
265
|
+
return {
|
|
266
|
+
key: '__hypequery_openapi__',
|
|
267
|
+
method: 'GET',
|
|
268
|
+
inputSchema: undefined,
|
|
269
|
+
outputSchema: z.any(),
|
|
270
|
+
handler: async () => {
|
|
271
|
+
if (!cachedDocument) {
|
|
272
|
+
cachedDocument = buildOpenApiDocument(getEndpoints(), options);
|
|
273
|
+
}
|
|
274
|
+
return cachedDocument;
|
|
275
|
+
},
|
|
276
|
+
query: undefined,
|
|
277
|
+
middlewares: [],
|
|
278
|
+
auth: null,
|
|
279
|
+
metadata: {
|
|
280
|
+
path,
|
|
281
|
+
method: 'GET',
|
|
282
|
+
name: 'OpenAPI schema',
|
|
283
|
+
summary: 'OpenAPI schema',
|
|
284
|
+
description: 'Generated OpenAPI specification for the registered endpoints',
|
|
285
|
+
tags: ['docs'],
|
|
286
|
+
requiresAuth: false,
|
|
287
|
+
deprecated: false,
|
|
288
|
+
visibility: 'internal',
|
|
289
|
+
},
|
|
290
|
+
cacheTtlMs: null,
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
export const createDocsEndpoint = (path, openapiPath, options) => ({
|
|
294
|
+
key: '__hypequery_docs__',
|
|
295
|
+
method: 'GET',
|
|
296
|
+
inputSchema: undefined,
|
|
297
|
+
outputSchema: z.string(),
|
|
298
|
+
handler: async () => buildDocsHtml(openapiPath, options),
|
|
299
|
+
query: undefined,
|
|
300
|
+
middlewares: [],
|
|
301
|
+
auth: null,
|
|
302
|
+
metadata: {
|
|
303
|
+
path,
|
|
304
|
+
method: 'GET',
|
|
305
|
+
name: 'Docs',
|
|
306
|
+
summary: 'API documentation',
|
|
307
|
+
description: 'Auto-generated documentation for your hypequery endpoints',
|
|
308
|
+
tags: ['docs'],
|
|
309
|
+
requiresAuth: false,
|
|
310
|
+
deprecated: false,
|
|
311
|
+
visibility: 'internal',
|
|
312
|
+
},
|
|
313
|
+
cacheTtlMs: null,
|
|
314
|
+
defaultHeaders: {
|
|
315
|
+
'content-type': 'text/html; charset=utf-8',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-agnostic query event logger for the serve layer.
|
|
3
|
+
*
|
|
4
|
+
* Fires for every endpoint execution regardless of the underlying
|
|
5
|
+
* query backend (ClickHouse, BigQuery, mock data, etc.).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Event emitted by the serve-layer query logger.
|
|
9
|
+
*/
|
|
10
|
+
export interface ServeQueryEvent {
|
|
11
|
+
requestId: string;
|
|
12
|
+
endpointKey: string;
|
|
13
|
+
path: string;
|
|
14
|
+
method: string;
|
|
15
|
+
status: 'started' | 'completed' | 'error';
|
|
16
|
+
startTime: number;
|
|
17
|
+
endTime?: number;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
input?: unknown;
|
|
20
|
+
responseStatus?: number;
|
|
21
|
+
error?: Error;
|
|
22
|
+
result?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Callback for serve query events.
|
|
26
|
+
*/
|
|
27
|
+
export type ServeQueryEventCallback = (event: ServeQueryEvent) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Serve-layer query event emitter.
|
|
30
|
+
*
|
|
31
|
+
* Created per `defineServe()` call — not a singleton.
|
|
32
|
+
* Emits events at the request lifecycle level so that dev tools,
|
|
33
|
+
* logging, and analytics work with any query backend.
|
|
34
|
+
*/
|
|
35
|
+
export declare class ServeQueryLogger {
|
|
36
|
+
private listeners;
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to query events.
|
|
39
|
+
* @returns Unsubscribe function.
|
|
40
|
+
*/
|
|
41
|
+
on(callback: ServeQueryEventCallback): () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Emit a query event to all listeners.
|
|
44
|
+
*/
|
|
45
|
+
emit(event: ServeQueryEvent): void;
|
|
46
|
+
/**
|
|
47
|
+
* Number of active listeners.
|
|
48
|
+
*/
|
|
49
|
+
get listenerCount(): number;
|
|
50
|
+
/**
|
|
51
|
+
* Remove all listeners.
|
|
52
|
+
*/
|
|
53
|
+
removeAll(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Format a query event as a human-readable log line.
|
|
57
|
+
* Returns null for 'started' events (only logs completions).
|
|
58
|
+
*/
|
|
59
|
+
export declare function formatQueryEvent(event: ServeQueryEvent): string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Format a query event as a structured JSON string for log aggregators.
|
|
62
|
+
* Returns null for 'started' events (only logs completions).
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatQueryEventJSON(event: ServeQueryEvent): string | null;
|
|
65
|
+
//# sourceMappingURL=query-logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-logger.d.ts","sourceRoot":"","sources":["../src/query-logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;AAEvE;;;;;;GAMG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,SAAS,CAAsC;IAEvD;;;OAGG;IACH,EAAE,CAAC,QAAQ,EAAE,uBAAuB,GAAG,MAAM,IAAI;IAOjD;;OAEG;IACH,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;IAUlC;;OAEG;IACH,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;OAEG;IACH,SAAS,IAAI,IAAI;CAGlB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAYtE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAiB1E"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-agnostic query event logger for the serve layer.
|
|
3
|
+
*
|
|
4
|
+
* Fires for every endpoint execution regardless of the underlying
|
|
5
|
+
* query backend (ClickHouse, BigQuery, mock data, etc.).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Serve-layer query event emitter.
|
|
9
|
+
*
|
|
10
|
+
* Created per `defineServe()` call — not a singleton.
|
|
11
|
+
* Emits events at the request lifecycle level so that dev tools,
|
|
12
|
+
* logging, and analytics work with any query backend.
|
|
13
|
+
*/
|
|
14
|
+
export class ServeQueryLogger {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.listeners = new Set();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe to query events.
|
|
20
|
+
* @returns Unsubscribe function.
|
|
21
|
+
*/
|
|
22
|
+
on(callback) {
|
|
23
|
+
this.listeners.add(callback);
|
|
24
|
+
return () => {
|
|
25
|
+
this.listeners.delete(callback);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Emit a query event to all listeners.
|
|
30
|
+
*/
|
|
31
|
+
emit(event) {
|
|
32
|
+
for (const listener of this.listeners) {
|
|
33
|
+
try {
|
|
34
|
+
listener(event);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Ignore listener errors
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Number of active listeners.
|
|
43
|
+
*/
|
|
44
|
+
get listenerCount() {
|
|
45
|
+
return this.listeners.size;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Remove all listeners.
|
|
49
|
+
*/
|
|
50
|
+
removeAll() {
|
|
51
|
+
this.listeners.clear();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Format a query event as a human-readable log line.
|
|
56
|
+
* Returns null for 'started' events (only logs completions).
|
|
57
|
+
*/
|
|
58
|
+
export function formatQueryEvent(event) {
|
|
59
|
+
if (event.status === 'started')
|
|
60
|
+
return null;
|
|
61
|
+
const status = event.status === 'completed' ? '✓' : '✗';
|
|
62
|
+
const duration = event.durationMs != null ? `${event.durationMs}ms` : '?';
|
|
63
|
+
const code = event.responseStatus ?? (event.status === 'error' ? 500 : 200);
|
|
64
|
+
let line = ` ${status} ${event.method} ${event.path} → ${code} (${duration})`;
|
|
65
|
+
if (event.status === 'error' && event.error) {
|
|
66
|
+
line += ` — ${event.error.message}`;
|
|
67
|
+
}
|
|
68
|
+
return line;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Format a query event as a structured JSON string for log aggregators.
|
|
72
|
+
* Returns null for 'started' events (only logs completions).
|
|
73
|
+
*/
|
|
74
|
+
export function formatQueryEventJSON(event) {
|
|
75
|
+
if (event.status === 'started')
|
|
76
|
+
return null;
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
level: event.status === 'error' ? 'error' : 'info',
|
|
79
|
+
msg: `${event.method} ${event.path}`,
|
|
80
|
+
requestId: event.requestId,
|
|
81
|
+
endpoint: event.endpointKey,
|
|
82
|
+
path: event.path,
|
|
83
|
+
method: event.method,
|
|
84
|
+
status: event.responseStatus ?? (event.status === 'error' ? 500 : 200),
|
|
85
|
+
durationMs: event.durationMs,
|
|
86
|
+
...(event.status === 'error' && event.error
|
|
87
|
+
? { error: event.error.message }
|
|
88
|
+
: {}),
|
|
89
|
+
timestamp: new Date(event.endTime ?? event.startTime).toISOString(),
|
|
90
|
+
});
|
|
91
|
+
}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,WAAW,EAIX,YAAY,EACZ,WAAW,EACX,mBAAmB,EAEnB,gBAAgB,EAEhB,gBAAgB,EAIhB,eAAe,EAMhB,MAAM,YAAY,CAAC;AAWpB,eAAO,MAAM,WAAW,GACtB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClE,KAAK,SAAS,WAAW,GAAG,WAAW,EACvC,KAAK,CAAC,QAAQ,SAAS,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,EAE1F,QAAQ,WAAW,CAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,KAC7C,YAAY,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,CAgL3E,CAAC;AA8BF,KAAK,uBAAuB,CAC1B,QAAQ,EACR,KAAK,SAAS,WAAW,IACvB,QAAQ,SAAS,mBAAmB,CAAC,MAAM,QAAQ,EAAE,KAAK,CAAC,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEnF,KAAK,uBAAuB,CAC1B,QAAQ,SAAS,mBAAmB,CAAC,GAAG,EAAE,KAAK,CAAC,EAChD,KAAK,SAAS,WAAW,IACvB,IAAI,CACN,WAAW,CACT,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,EACxC,KAAK,EACL,eAAe,CAAC,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,KAAK,CAAC,CACjE,EACD,SAAS,GAAG,SAAS,CACtB,GAAG;IAAE,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;AAQ1B,eAAO,MAAM,SAAS,GACpB,QAAQ,SAAS,mBAAmB,CAAC,GAAG,EAAE,KAAK,CAAC,EAChD,KAAK,SAAS,WAAW,GAAG,WAAW,EACvC,SAAS,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAG,gBAAgB,CACpE,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,EACxC,KAAK,CAsBN,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -1,394 +1,12 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
3
2
|
import { startNodeServer } from "./adapters/node.js";
|
|
4
3
|
import { createEndpoint } from "./endpoint.js";
|
|
5
|
-
import { buildOpenApiDocument } from "./openapi.js";
|
|
6
4
|
import { applyBasePath, normalizeRoutePath, ServeRouter } from "./router.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
if (!value) {
|
|
11
|
-
return [];
|
|
12
|
-
}
|
|
13
|
-
return Array.isArray(value) ? value : [value];
|
|
14
|
-
};
|
|
15
|
-
const generateRequestId = () => {
|
|
16
|
-
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
17
|
-
};
|
|
18
|
-
const createProcedureBuilder = () => {
|
|
19
|
-
const build = (state) => {
|
|
20
|
-
return {
|
|
21
|
-
input: (schema) => build({ ...state, inputSchema: schema }),
|
|
22
|
-
output: (schema) => build({ ...state, outputSchema: schema }),
|
|
23
|
-
describe: (description) => build({ ...state, description }),
|
|
24
|
-
name: (name) => build({ ...state, name }),
|
|
25
|
-
summary: (summary) => build({ ...state, summary }),
|
|
26
|
-
tag: (tag) => build({
|
|
27
|
-
...state,
|
|
28
|
-
tags: Array.from(new Set([...state.tags, tag])),
|
|
29
|
-
}),
|
|
30
|
-
tags: (tags) => build({
|
|
31
|
-
...state,
|
|
32
|
-
tags: Array.from(new Set([...state.tags, ...(tags ?? [])])),
|
|
33
|
-
}),
|
|
34
|
-
method: (method) => build({ ...state, method }),
|
|
35
|
-
cache: (ttlMs) => build({ ...state, cacheTtlMs: ttlMs }),
|
|
36
|
-
auth: (strategy) => build({ ...state, auth: strategy }),
|
|
37
|
-
tenant: (config) => build({ ...state, tenant: config }),
|
|
38
|
-
custom: (custom) => build({
|
|
39
|
-
...state,
|
|
40
|
-
custom: { ...(state.custom ?? {}), ...custom },
|
|
41
|
-
}),
|
|
42
|
-
use: (...middlewares) => build({
|
|
43
|
-
...state,
|
|
44
|
-
middlewares: [...state.middlewares, ...middlewares],
|
|
45
|
-
}),
|
|
46
|
-
query: (executable) => {
|
|
47
|
-
const base = {
|
|
48
|
-
description: state.description,
|
|
49
|
-
name: state.name,
|
|
50
|
-
summary: state.summary,
|
|
51
|
-
tags: state.tags,
|
|
52
|
-
method: state.method,
|
|
53
|
-
inputSchema: state.inputSchema,
|
|
54
|
-
outputSchema: state.outputSchema,
|
|
55
|
-
cacheTtlMs: state.cacheTtlMs,
|
|
56
|
-
auth: typeof state.auth === "undefined" ? null : state.auth,
|
|
57
|
-
tenant: state.tenant,
|
|
58
|
-
custom: state.custom,
|
|
59
|
-
middlewares: state.middlewares,
|
|
60
|
-
query: executable,
|
|
61
|
-
};
|
|
62
|
-
return base;
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
return build({ tags: [], middlewares: [] });
|
|
67
|
-
};
|
|
68
|
-
const getRequestId = (request) => {
|
|
69
|
-
return (request.headers["x-request-id"] ??
|
|
70
|
-
request.headers["x-trace-id"] ??
|
|
71
|
-
generateRequestId());
|
|
72
|
-
};
|
|
73
|
-
const safeInvokeHook = async (name, hook, payload) => {
|
|
74
|
-
if (!hook) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
try {
|
|
78
|
-
await hook(payload);
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
console.error(`[hypequery/serve] ${name} hook failed`, error);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
const mergeTags = (existing, next) => {
|
|
85
|
-
const merged = [...existing, ...(next ?? [])];
|
|
86
|
-
return Array.from(new Set(merged.filter(Boolean)));
|
|
87
|
-
};
|
|
88
|
-
const createErrorResponse = (status, type, message, details) => {
|
|
89
|
-
return {
|
|
90
|
-
status,
|
|
91
|
-
body: {
|
|
92
|
-
error: {
|
|
93
|
-
type,
|
|
94
|
-
message,
|
|
95
|
-
...(details ? { details } : {}),
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
};
|
|
100
|
-
const buildContextInput = (request) => {
|
|
101
|
-
if (request.body !== undefined && request.body !== null) {
|
|
102
|
-
return request.body;
|
|
103
|
-
}
|
|
104
|
-
if (request.query && Object.keys(request.query).length > 0) {
|
|
105
|
-
return request.query;
|
|
106
|
-
}
|
|
107
|
-
return {};
|
|
108
|
-
};
|
|
109
|
-
const runMiddlewares = async (middlewares, ctx, handler) => {
|
|
110
|
-
let index = middlewares.length - 1;
|
|
111
|
-
let next = handler;
|
|
112
|
-
while (index >= 0) {
|
|
113
|
-
const middleware = middlewares[index];
|
|
114
|
-
const downstream = next;
|
|
115
|
-
next = () => middleware(ctx, downstream);
|
|
116
|
-
index -= 1;
|
|
117
|
-
}
|
|
118
|
-
return next();
|
|
119
|
-
};
|
|
120
|
-
const authenticateRequest = async (strategies, request, metadata) => {
|
|
121
|
-
for (const strategy of strategies) {
|
|
122
|
-
const result = await strategy({
|
|
123
|
-
request,
|
|
124
|
-
endpoint: metadata,
|
|
125
|
-
});
|
|
126
|
-
if (result) {
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return null;
|
|
131
|
-
};
|
|
132
|
-
const gatherAuthStrategies = (endpointStrategy, globalStrategies) => {
|
|
133
|
-
const strategies = [];
|
|
134
|
-
if (endpointStrategy) {
|
|
135
|
-
strategies.push(endpointStrategy);
|
|
136
|
-
}
|
|
137
|
-
return [...strategies, ...globalStrategies];
|
|
138
|
-
};
|
|
139
|
-
const computeRequiresAuth = (metadata, endpointStrategy, globalStrategies) => {
|
|
140
|
-
if (typeof metadata.requiresAuth === "boolean") {
|
|
141
|
-
return metadata.requiresAuth;
|
|
142
|
-
}
|
|
143
|
-
if (endpointStrategy) {
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
return globalStrategies.length > 0;
|
|
147
|
-
};
|
|
148
|
-
const validateInput = (schema, payload) => {
|
|
149
|
-
if (!schema) {
|
|
150
|
-
return { success: true, data: payload };
|
|
151
|
-
}
|
|
152
|
-
const result = schema.safeParse(payload);
|
|
153
|
-
return result.success
|
|
154
|
-
? { success: true, data: result.data }
|
|
155
|
-
: { success: false, error: result.error };
|
|
156
|
-
};
|
|
157
|
-
const createOpenApiEndpoint = (path, getEndpoints, openapiOptions) => {
|
|
158
|
-
// Cache the OpenAPI document to avoid rebuilding on every request
|
|
159
|
-
let cachedDocument = null;
|
|
160
|
-
return {
|
|
161
|
-
key: "__hypequery_openapi__",
|
|
162
|
-
method: "GET",
|
|
163
|
-
inputSchema: undefined,
|
|
164
|
-
outputSchema: z.any(),
|
|
165
|
-
handler: async () => {
|
|
166
|
-
if (!cachedDocument) {
|
|
167
|
-
cachedDocument = buildOpenApiDocument(getEndpoints(), openapiOptions);
|
|
168
|
-
}
|
|
169
|
-
return cachedDocument;
|
|
170
|
-
},
|
|
171
|
-
query: undefined,
|
|
172
|
-
middlewares: [],
|
|
173
|
-
auth: null,
|
|
174
|
-
metadata: {
|
|
175
|
-
path,
|
|
176
|
-
method: "GET",
|
|
177
|
-
name: "OpenAPI schema",
|
|
178
|
-
summary: "OpenAPI schema",
|
|
179
|
-
description: "Generated OpenAPI specification for the registered endpoints",
|
|
180
|
-
tags: ["docs"],
|
|
181
|
-
requiresAuth: false,
|
|
182
|
-
deprecated: false,
|
|
183
|
-
visibility: "internal",
|
|
184
|
-
},
|
|
185
|
-
cacheTtlMs: null,
|
|
186
|
-
};
|
|
187
|
-
};
|
|
188
|
-
const createDocsEndpoint = (path, openapiPath, options) => ({
|
|
189
|
-
key: "__hypequery_docs__",
|
|
190
|
-
method: "GET",
|
|
191
|
-
inputSchema: undefined,
|
|
192
|
-
outputSchema: z.string(),
|
|
193
|
-
handler: async () => buildDocsHtml(openapiPath, options),
|
|
194
|
-
query: undefined,
|
|
195
|
-
middlewares: [],
|
|
196
|
-
auth: null,
|
|
197
|
-
metadata: {
|
|
198
|
-
path,
|
|
199
|
-
method: "GET",
|
|
200
|
-
name: "Docs",
|
|
201
|
-
summary: "API documentation",
|
|
202
|
-
description: "Auto-generated documentation for your hypequery endpoints",
|
|
203
|
-
tags: ["docs"],
|
|
204
|
-
requiresAuth: false,
|
|
205
|
-
deprecated: false,
|
|
206
|
-
visibility: "internal",
|
|
207
|
-
},
|
|
208
|
-
cacheTtlMs: null,
|
|
209
|
-
defaultHeaders: {
|
|
210
|
-
"content-type": "text/html; charset=utf-8",
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
const cloneContext = (ctx) => (ctx ? { ...ctx } : {});
|
|
214
|
-
const resolveContext = async (factory, request, auth) => {
|
|
215
|
-
if (!factory) {
|
|
216
|
-
return {};
|
|
217
|
-
}
|
|
218
|
-
if (typeof factory === "function") {
|
|
219
|
-
const value = await factory({ request, auth });
|
|
220
|
-
return cloneContext(value);
|
|
221
|
-
}
|
|
222
|
-
return cloneContext(factory);
|
|
223
|
-
};
|
|
224
|
-
const executeEndpoint = async (options) => {
|
|
225
|
-
const { endpoint, request, requestId, authStrategies, contextFactory, globalMiddlewares, globalTenantConfig, hooks, additionalContext, } = options;
|
|
226
|
-
const locals = {};
|
|
227
|
-
let cacheTtlMs = endpoint.cacheTtlMs ?? null;
|
|
228
|
-
const setCacheTtl = (ttl) => {
|
|
229
|
-
cacheTtlMs = ttl;
|
|
230
|
-
};
|
|
231
|
-
const context = {
|
|
232
|
-
request,
|
|
233
|
-
input: buildContextInput(request),
|
|
234
|
-
auth: null,
|
|
235
|
-
metadata: endpoint.metadata,
|
|
236
|
-
locals,
|
|
237
|
-
setCacheTtl,
|
|
238
|
-
};
|
|
239
|
-
const startedAt = Date.now();
|
|
240
|
-
await safeInvokeHook("onRequestStart", hooks.onRequestStart, {
|
|
241
|
-
requestId,
|
|
242
|
-
queryKey: endpoint.key,
|
|
243
|
-
metadata: endpoint.metadata,
|
|
244
|
-
request,
|
|
245
|
-
auth: context.auth,
|
|
246
|
-
});
|
|
247
|
-
try {
|
|
248
|
-
const endpointAuth = endpoint.auth ?? null;
|
|
249
|
-
const strategies = gatherAuthStrategies(endpointAuth, authStrategies);
|
|
250
|
-
const requiresAuth = computeRequiresAuth(endpoint.metadata, endpointAuth, authStrategies);
|
|
251
|
-
const metadataWithAuth = {
|
|
252
|
-
...endpoint.metadata,
|
|
253
|
-
requiresAuth,
|
|
254
|
-
};
|
|
255
|
-
context.metadata = metadataWithAuth;
|
|
256
|
-
const authContext = await authenticateRequest(strategies, request, metadataWithAuth);
|
|
257
|
-
if (!authContext && requiresAuth) {
|
|
258
|
-
await safeInvokeHook("onAuthFailure", hooks.onAuthFailure, {
|
|
259
|
-
requestId,
|
|
260
|
-
queryKey: endpoint.key,
|
|
261
|
-
metadata: metadataWithAuth,
|
|
262
|
-
request,
|
|
263
|
-
auth: context.auth,
|
|
264
|
-
reason: "MISSING",
|
|
265
|
-
});
|
|
266
|
-
return createErrorResponse(401, "UNAUTHORIZED", "Authentication required", {
|
|
267
|
-
reason: "missing_credentials",
|
|
268
|
-
strategies_attempted: strategies.length,
|
|
269
|
-
endpoint: endpoint.metadata.path,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
// After the auth check above, if requiresAuth is true, authContext is guaranteed to be non-null
|
|
273
|
-
context.auth = authContext;
|
|
274
|
-
const hydratedContext = await resolveContext(contextFactory, request, authContext);
|
|
275
|
-
Object.assign(context, hydratedContext, additionalContext);
|
|
276
|
-
// Tenant isolation: Extract and validate tenant ID if configured
|
|
277
|
-
// Use endpoint-specific config, or fall back to global config
|
|
278
|
-
const tenantConfig = endpoint.tenant ?? globalTenantConfig;
|
|
279
|
-
if (tenantConfig) {
|
|
280
|
-
const tenantRequired = tenantConfig.required !== false; // Default to true
|
|
281
|
-
const tenantId = authContext ? tenantConfig.extract(authContext) : null;
|
|
282
|
-
if (!tenantId && tenantRequired) {
|
|
283
|
-
const errorMessage = tenantConfig.errorMessage ??
|
|
284
|
-
"Tenant context is required but could not be determined from authentication";
|
|
285
|
-
await safeInvokeHook("onError", hooks.onError, {
|
|
286
|
-
requestId,
|
|
287
|
-
queryKey: endpoint.key,
|
|
288
|
-
metadata: metadataWithAuth,
|
|
289
|
-
request,
|
|
290
|
-
auth: context.auth,
|
|
291
|
-
durationMs: Date.now() - startedAt,
|
|
292
|
-
error: new Error(errorMessage),
|
|
293
|
-
});
|
|
294
|
-
return createErrorResponse(403, "UNAUTHORIZED", errorMessage, {
|
|
295
|
-
reason: "missing_tenant_context",
|
|
296
|
-
tenant_required: true,
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
if (tenantId) {
|
|
300
|
-
context.tenantId = tenantId;
|
|
301
|
-
// Auto-inject tenant filtering if configured
|
|
302
|
-
const mode = tenantConfig.mode ?? 'manual'; // Default to manual for backward compatibility
|
|
303
|
-
const column = tenantConfig.column;
|
|
304
|
-
if (mode === 'auto-inject' && column) {
|
|
305
|
-
// Wrap all query builders in the context to auto-inject tenant filters
|
|
306
|
-
const contextValues = context;
|
|
307
|
-
for (const key of Object.keys(contextValues)) {
|
|
308
|
-
const value = contextValues[key];
|
|
309
|
-
// Check if it looks like a query builder (has a table method)
|
|
310
|
-
if (value && typeof value === 'object' && 'table' in value && typeof value.table === 'function') {
|
|
311
|
-
contextValues[key] = createTenantScope(value, { tenantId, column });
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
else if (mode === 'manual') {
|
|
316
|
-
// Warn developers in manual mode to ensure they manually filter
|
|
317
|
-
warnTenantMisconfiguration({
|
|
318
|
-
queryKey: endpoint.key,
|
|
319
|
-
hasTenantConfig: true,
|
|
320
|
-
hasTenantId: true,
|
|
321
|
-
mode: 'manual',
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
else if (tenantConfig && !tenantRequired) {
|
|
326
|
-
// Optional tenant mode - warn if no tenant config when accessing user data
|
|
327
|
-
warnTenantMisconfiguration({
|
|
328
|
-
queryKey: endpoint.key,
|
|
329
|
-
hasTenantConfig: true,
|
|
330
|
-
hasTenantId: false,
|
|
331
|
-
mode: tenantConfig.mode,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
const validationResult = validateInput(endpoint.inputSchema, context.input);
|
|
336
|
-
if (!validationResult.success) {
|
|
337
|
-
await safeInvokeHook("onError", hooks.onError, {
|
|
338
|
-
requestId,
|
|
339
|
-
queryKey: endpoint.key,
|
|
340
|
-
metadata: metadataWithAuth,
|
|
341
|
-
request,
|
|
342
|
-
auth: context.auth,
|
|
343
|
-
durationMs: Date.now() - startedAt,
|
|
344
|
-
error: validationResult.error,
|
|
345
|
-
});
|
|
346
|
-
return createErrorResponse(400, "VALIDATION_ERROR", "Request validation failed", {
|
|
347
|
-
issues: validationResult.error.issues,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
context.input = validationResult.data;
|
|
351
|
-
const pipeline = [
|
|
352
|
-
...globalMiddlewares,
|
|
353
|
-
...endpoint.middlewares,
|
|
354
|
-
];
|
|
355
|
-
const result = await runMiddlewares(pipeline, context, () => endpoint.handler(context));
|
|
356
|
-
const headers = { ...(endpoint.defaultHeaders ?? {}) };
|
|
357
|
-
if (typeof cacheTtlMs === "number") {
|
|
358
|
-
headers["cache-control"] = cacheTtlMs > 0 ? `public, max-age=${Math.floor(cacheTtlMs / 1000)}` : "no-store";
|
|
359
|
-
}
|
|
360
|
-
const durationMs = Date.now() - startedAt;
|
|
361
|
-
await safeInvokeHook("onRequestEnd", hooks.onRequestEnd, {
|
|
362
|
-
requestId,
|
|
363
|
-
queryKey: endpoint.key,
|
|
364
|
-
metadata: metadataWithAuth,
|
|
365
|
-
request,
|
|
366
|
-
auth: context.auth,
|
|
367
|
-
durationMs,
|
|
368
|
-
result,
|
|
369
|
-
});
|
|
370
|
-
return {
|
|
371
|
-
status: 200,
|
|
372
|
-
headers,
|
|
373
|
-
body: result,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
catch (error) {
|
|
377
|
-
await safeInvokeHook("onError", hooks.onError, {
|
|
378
|
-
requestId,
|
|
379
|
-
queryKey: endpoint.key,
|
|
380
|
-
metadata: context.metadata,
|
|
381
|
-
request,
|
|
382
|
-
auth: context.auth,
|
|
383
|
-
durationMs: Date.now() - startedAt,
|
|
384
|
-
error,
|
|
385
|
-
});
|
|
386
|
-
const message = error instanceof Error ? error.message : "Unexpected error";
|
|
387
|
-
return createErrorResponse(500, "INTERNAL_SERVER_ERROR", message);
|
|
388
|
-
}
|
|
389
|
-
};
|
|
5
|
+
import { createProcedureBuilder } from "./builder.js";
|
|
6
|
+
import { ensureArray, mergeTags } from "./utils.js";
|
|
7
|
+
import { createDocsEndpoint, createOpenApiEndpoint, createServeHandler, executeEndpoint, } from "./pipeline.js";
|
|
390
8
|
export const defineServe = (config) => {
|
|
391
|
-
const basePath = config.basePath ?? "";
|
|
9
|
+
const basePath = config.basePath ?? "/api/analytics";
|
|
392
10
|
const router = new ServeRouter(basePath);
|
|
393
11
|
const globalMiddlewares = [
|
|
394
12
|
...(config.middlewares ?? []),
|
|
@@ -414,23 +32,14 @@ export const defineServe = (config) => {
|
|
|
414
32
|
for (const key of Object.keys(configuredQueries)) {
|
|
415
33
|
registerQuery(key, configuredQueries[key]);
|
|
416
34
|
}
|
|
417
|
-
const handler =
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
request,
|
|
426
|
-
requestId,
|
|
427
|
-
authStrategies,
|
|
428
|
-
contextFactory,
|
|
429
|
-
globalMiddlewares,
|
|
430
|
-
globalTenantConfig,
|
|
431
|
-
hooks,
|
|
432
|
-
});
|
|
433
|
-
};
|
|
35
|
+
const handler = createServeHandler({
|
|
36
|
+
router,
|
|
37
|
+
globalMiddlewares,
|
|
38
|
+
authStrategies,
|
|
39
|
+
tenantConfig: globalTenantConfig,
|
|
40
|
+
contextFactory,
|
|
41
|
+
hooks,
|
|
42
|
+
});
|
|
434
43
|
// Track route configuration for client config extraction
|
|
435
44
|
const routeConfig = {};
|
|
436
45
|
const executeQuery = async (key, options) => {
|
|
@@ -446,15 +55,13 @@ export const defineServe = (config) => {
|
|
|
446
55
|
body: options?.input ?? options?.request?.body,
|
|
447
56
|
raw: options?.request?.raw,
|
|
448
57
|
};
|
|
449
|
-
const requestId = getRequestId(request);
|
|
450
58
|
const response = await executeEndpoint({
|
|
451
59
|
endpoint,
|
|
452
60
|
request,
|
|
453
|
-
requestId,
|
|
454
61
|
authStrategies,
|
|
455
62
|
contextFactory,
|
|
456
63
|
globalMiddlewares,
|
|
457
|
-
globalTenantConfig,
|
|
64
|
+
tenantConfig: globalTenantConfig,
|
|
458
65
|
hooks,
|
|
459
66
|
additionalContext: options?.context,
|
|
460
67
|
});
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,GAAG,IAAI,KAAG,CAAC,EAKnE,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,UAAU,MAAM,EAAE,EAAE,OAAO,MAAM,EAAE,aAG5D,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAO,MAKpC,CAAC"}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const ensureArray = (value) => {
|
|
2
|
+
if (!value) {
|
|
3
|
+
return [];
|
|
4
|
+
}
|
|
5
|
+
return Array.isArray(value) ? value : [value];
|
|
6
|
+
};
|
|
7
|
+
export const mergeTags = (existing, next) => {
|
|
8
|
+
const merged = [...existing, ...(next ?? [])];
|
|
9
|
+
return Array.from(new Set(merged.filter(Boolean)));
|
|
10
|
+
};
|
|
11
|
+
export const generateRequestId = () => {
|
|
12
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
13
|
+
return globalThis.crypto.randomUUID();
|
|
14
|
+
}
|
|
15
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
16
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/serve",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Declarative HTTP server for exposing hypequery analytics endpoints",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -47,4 +47,4 @@
|
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
package/dist/queries.d.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
import type { AuthContext, ServeQueriesMap } from "./types.js";
|
|
2
|
-
export declare const defineQueries: <TContext extends Record<string, unknown> = Record<string, unknown>, TAuth extends AuthContext = AuthContext, const TQueries extends ServeQueriesMap<TContext, TAuth> = ServeQueriesMap<TContext, TAuth>>(queries: TQueries) => TQueries;
|
|
3
|
-
//# sourceMappingURL=queries.d.ts.map
|
package/dist/queries.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../src/queries.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE/D,eAAO,MAAM,aAAa,GACxB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClE,KAAK,SAAS,WAAW,GAAG,WAAW,EACvC,KAAK,CAAC,QAAQ,SAAS,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,EAE1F,SAAS,QAAQ,KAChB,QAAmB,CAAC"}
|
package/dist/queries.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const defineQueries = (queries) => queries;
|
package/dist/query.d.ts
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import type { ZodTypeAny } from "zod";
|
|
2
|
-
import type { AuthContext, SchemaOutput, ServeQueryConfig } from "./types.js";
|
|
3
|
-
export declare const defineQuery: <TInputSchema extends ZodTypeAny | undefined = undefined, TOutputSchema extends ZodTypeAny = ZodTypeAny, TContext extends Record<string, unknown> = Record<string, unknown>, TAuth extends AuthContext = AuthContext, TResult = SchemaOutput<TOutputSchema>>(definition: ServeQueryConfig<TInputSchema, TOutputSchema, TContext, TAuth, TResult>) => ServeQueryConfig<TInputSchema, TOutputSchema, TContext, TAuth, TResult>;
|
|
4
|
-
//# sourceMappingURL=query.d.ts.map
|
package/dist/query.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,KAAK,CAAC;AAEtC,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9E,eAAO,MAAM,WAAW,GACtB,YAAY,SAAS,UAAU,GAAG,SAAS,GAAG,SAAS,EACvD,aAAa,SAAS,UAAU,GAAG,UAAU,EAC7C,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClE,KAAK,SAAS,WAAW,GAAG,WAAW,EACvC,OAAO,GAAG,YAAY,CAAC,aAAa,CAAC,EAErC,YAAY,gBAAgB,CAAC,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,CAAC,KAClF,gBAAgB,CAAC,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,CAAe,CAAC"}
|
package/dist/query.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const defineQuery = (definition) => definition;
|
package/dist/sdk-generator.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"sdk-generator.d.ts","sourceRoot":"","sources":["../src/sdk-generator.ts"],"names":[],"mappings":"AAuHA,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAuBD,eAAO,MAAM,iBAAiB,GAAU,SAAS,kBAAkB,kBAalE,CAAC"}
|
package/dist/sdk-generator.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
-
import openapiTS, { astToString } from "openapi-typescript";
|
|
4
|
-
const TEMPLATE_CLIENT = (clientName) => [
|
|
5
|
-
'import type { paths } from "./types";',
|
|
6
|
-
'',
|
|
7
|
-
'type FetchImpl = typeof fetch;',
|
|
8
|
-
'',
|
|
9
|
-
'type ExtractJsonResponse<T> = T extends { responses: infer Responses }',
|
|
10
|
-
' ? Responses extends Record<string, any>',
|
|
11
|
-
' ? Responses[200] extends { content: { "application/json": infer R } }',
|
|
12
|
-
' ? R',
|
|
13
|
-
' : Responses[201] extends { content: { "application/json": infer R } }',
|
|
14
|
-
' ? R',
|
|
15
|
-
' : unknown',
|
|
16
|
-
' : unknown',
|
|
17
|
-
' : unknown;',
|
|
18
|
-
'',
|
|
19
|
-
'type ExtractBody<T> = T extends { requestBody: { content: { "application/json": infer Body } } }',
|
|
20
|
-
' ? Body',
|
|
21
|
-
' : undefined;',
|
|
22
|
-
'',
|
|
23
|
-
'type ExtractQuery<T> = T extends { parameters: { query: infer Params } }',
|
|
24
|
-
' ? Params',
|
|
25
|
-
' : undefined;',
|
|
26
|
-
'',
|
|
27
|
-
'type RequestOptions<Path extends keyof paths, Method extends keyof paths[Path]> = {',
|
|
28
|
-
' path: Path;',
|
|
29
|
-
' method: Method;',
|
|
30
|
-
' query?: ExtractQuery<paths[Path][Method]>;',
|
|
31
|
-
' body?: ExtractBody<paths[Path][Method]>;',
|
|
32
|
-
' headers?: Record<string, string>;',
|
|
33
|
-
' fetch?: FetchImpl;',
|
|
34
|
-
'} & Omit<RequestInit, "body" | "method">;',
|
|
35
|
-
'',
|
|
36
|
-
'interface ClientConfig {',
|
|
37
|
-
' baseUrl: string;',
|
|
38
|
-
' headers?: Record<string, string>;',
|
|
39
|
-
' fetch?: FetchImpl;',
|
|
40
|
-
'}',
|
|
41
|
-
'',
|
|
42
|
-
'const encodeQueryValue = (value: unknown) =>',
|
|
43
|
-
' Array.isArray(value)',
|
|
44
|
-
' ? value.map((v) => encodeURIComponent(String(v))).join(",")',
|
|
45
|
-
' : encodeURIComponent(String(value));',
|
|
46
|
-
'',
|
|
47
|
-
'const buildUrl = (baseUrl: string, path: string, query?: Record<string, unknown>) => {',
|
|
48
|
-
' const url = new URL(path, baseUrl);',
|
|
49
|
-
'',
|
|
50
|
-
' if (query) {',
|
|
51
|
-
' Object.entries(query).forEach(([key, value]) => {',
|
|
52
|
-
' if (value === undefined || value === null) {',
|
|
53
|
-
' return;',
|
|
54
|
-
' }',
|
|
55
|
-
'',
|
|
56
|
-
' if (Array.isArray(value)) {',
|
|
57
|
-
' value.forEach((entry) => url.searchParams.append(key, String(entry)));',
|
|
58
|
-
' } else if (typeof value === "object") {',
|
|
59
|
-
' url.searchParams.append(key, encodeQueryValue(JSON.stringify(value)));',
|
|
60
|
-
' } else {',
|
|
61
|
-
' url.searchParams.append(key, encodeQueryValue(value));',
|
|
62
|
-
' }',
|
|
63
|
-
' });',
|
|
64
|
-
' }',
|
|
65
|
-
'',
|
|
66
|
-
' return url;',
|
|
67
|
-
'};',
|
|
68
|
-
'',
|
|
69
|
-
`export class ${clientName} {`,
|
|
70
|
-
' constructor(private readonly config: ClientConfig) {}',
|
|
71
|
-
'',
|
|
72
|
-
' async request<Path extends keyof paths, Method extends keyof paths[Path]>(',
|
|
73
|
-
' options: RequestOptions<Path, Method>',
|
|
74
|
-
' ): Promise<ExtractJsonResponse<paths[Path][Method]>> {',
|
|
75
|
-
' const target = buildUrl(this.config.baseUrl, options.path as string, options.query);',
|
|
76
|
-
' const fetchImpl = options.fetch ?? this.config.fetch ?? globalThis.fetch;',
|
|
77
|
-
'',
|
|
78
|
-
' if (!fetchImpl) {',
|
|
79
|
-
' throw new Error("No fetch implementation available. Provide one via config.fetch.");',
|
|
80
|
-
' }',
|
|
81
|
-
'',
|
|
82
|
-
' const headers = {',
|
|
83
|
-
' "content-type": "application/json",',
|
|
84
|
-
' ...(this.config.headers ?? {}),',
|
|
85
|
-
' ...(options.headers ?? {}),',
|
|
86
|
-
' };',
|
|
87
|
-
'',
|
|
88
|
-
' const response = await fetchImpl(target.toString(), {',
|
|
89
|
-
' ...options,',
|
|
90
|
-
' method: options.method as string,',
|
|
91
|
-
' headers,',
|
|
92
|
-
' body: options.body !== undefined ? JSON.stringify(options.body) : undefined,',
|
|
93
|
-
' });',
|
|
94
|
-
'',
|
|
95
|
-
' if (!response.ok) {',
|
|
96
|
-
' const errorText = await response.text();',
|
|
97
|
-
' throw new Error(`Request failed with status ${response.status}: ${errorText}`);',
|
|
98
|
-
' }',
|
|
99
|
-
'',
|
|
100
|
-
' const text = await response.text();',
|
|
101
|
-
' if (!text) {',
|
|
102
|
-
' return undefined as ExtractJsonResponse<paths[Path][Method]>;',
|
|
103
|
-
' }',
|
|
104
|
-
'',
|
|
105
|
-
' return JSON.parse(text);',
|
|
106
|
-
' }',
|
|
107
|
-
'}',
|
|
108
|
-
'',
|
|
109
|
-
`export const createClient = (config: ClientConfig) => new ${clientName}(config);`,
|
|
110
|
-
'',
|
|
111
|
-
].join("\n");
|
|
112
|
-
const TEMPLATE_INDEX = `export * from "./client";
|
|
113
|
-
export * from "./types";
|
|
114
|
-
`;
|
|
115
|
-
const isHttpUrl = (value) => /^https?:\/\//i.test(value);
|
|
116
|
-
const loadDocument = async (input) => {
|
|
117
|
-
if (isHttpUrl(input)) {
|
|
118
|
-
const response = await fetch(input);
|
|
119
|
-
if (!response.ok) {
|
|
120
|
-
throw new Error(`Failed to fetch OpenAPI spec from ${input}: ${response.status}`);
|
|
121
|
-
}
|
|
122
|
-
return response.json();
|
|
123
|
-
}
|
|
124
|
-
const absolutePath = isAbsolute(input) ? input : resolve(process.cwd(), input);
|
|
125
|
-
const contents = await readFile(absolutePath, "utf8");
|
|
126
|
-
return JSON.parse(contents);
|
|
127
|
-
};
|
|
128
|
-
const writeFileSafe = async (filePath, contents) => {
|
|
129
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
130
|
-
await writeFile(filePath, contents, "utf8");
|
|
131
|
-
};
|
|
132
|
-
export const generateSdkClient = async (options) => {
|
|
133
|
-
const document = await loadDocument(options.input);
|
|
134
|
-
const typesAst = await openapiTS(document, {
|
|
135
|
-
exportType: true,
|
|
136
|
-
});
|
|
137
|
-
const types = astToString(typesAst);
|
|
138
|
-
const outputDir = isAbsolute(options.output) ? options.output : resolve(process.cwd(), options.output);
|
|
139
|
-
const clientName = options.clientName ?? "HypeQueryClient";
|
|
140
|
-
await writeFileSafe(join(outputDir, "types.ts"), types);
|
|
141
|
-
await writeFileSafe(join(outputDir, "client.ts"), TEMPLATE_CLIENT(clientName));
|
|
142
|
-
await writeFileSafe(join(outputDir, "index.ts"), TEMPLATE_INDEX);
|
|
143
|
-
};
|