From e7febcb72cac7d2e8a23f2d8f5f34430e335f27c Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 17 Dec 2025 11:50:43 +0100 Subject: [PATCH] feat: config and typed endpoints --- .../client/src/appKitTypes.d.ts | 20 +- packages/app-kit-ui/src/js/endpoints/index.ts | 258 ++++++++++++++++++ packages/app-kit-ui/src/js/index.ts | 2 + .../src/react/hooks/use-analytics-query.ts | 22 +- packages/app-kit/src/analytics/analytics.ts | 15 +- packages/app-kit/src/server/index.ts | 10 + packages/app-kit/src/type-generator/index.ts | 131 ++++++++- .../app-kit/src/type-generator/vite-plugin.ts | 47 +++- packages/shared/src/endpoints/analytics.ts | 72 +++++ packages/shared/src/endpoints/index.ts | 5 + packages/shared/src/index.ts | 1 + 11 files changed, 545 insertions(+), 38 deletions(-) create mode 100644 packages/app-kit-ui/src/js/endpoints/index.ts create mode 100644 packages/shared/src/endpoints/analytics.ts create mode 100644 packages/shared/src/endpoints/index.ts diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index e30fec3..d796bb3 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -1,7 +1,25 @@ // Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx appkit-generate-types' or Vite plugin during build import "@databricks/app-kit-ui/react"; -import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker } from "@databricks/app-kit-ui/js"; +import "@databricks/app-kit-ui/js"; +import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker, EndpointFn } from "@databricks/app-kit-ui/js"; + +declare module "@databricks/app-kit-ui/js" { + interface AppKitPlugins { + reconnect: { + status: EndpointFn; + stream: EndpointFn; + }; + "telemetry-examples": { + combined: EndpointFn; + }; + analytics: { + arrowResult: EndpointFn<{ jobId: string }>; + queryAsUser: EndpointFn<{ query_key: string }>; + query: EndpointFn<{ query_key: string }>; + }; + } +} declare module "@databricks/app-kit-ui/react" { interface QueryRegistry { diff --git a/packages/app-kit-ui/src/js/endpoints/index.ts b/packages/app-kit-ui/src/js/endpoints/index.ts new file mode 100644 index 0000000..7a300e6 --- /dev/null +++ b/packages/app-kit-ui/src/js/endpoints/index.ts @@ -0,0 +1,258 @@ +/** + * Endpoints utility for accessing backend API routes. + * + * Provides a clean way to build API URLs with parameter substitution, + * reading from the runtime config injected by the server. + */ + +import type { AnalyticsEndpointParams } from "shared"; + +// Re-export for consumers +export type { AnalyticsEndpointParams } from "shared"; + +/** Map of endpoint names to their path templates for a plugin */ +export type PluginEndpointMap = Record; + +/** Map of plugin names to their endpoint maps */ +export type PluginEndpoints = Record; + +export interface RuntimeConfig { + appName: string; + queries: Record; + endpoints: PluginEndpoints; +} + +declare global { + interface Window { + __CONFIG__?: RuntimeConfig; + } +} + +/** + * Get the runtime config from the window object. + */ +export function getConfig(): RuntimeConfig { + if (!window.__CONFIG__) { + throw new Error( + "Runtime config not found. Make sure the server is injecting __CONFIG__.", + ); + } + return window.__CONFIG__; +} + +/** + * Substitute path parameters in a URL template. + * + * @param template - URL template with :param placeholders + * @param params - Parameters to substitute + * @returns The resolved URL + */ +function substituteParams( + template: string, + params: Record = {}, +): string { + let resolved = template; + for (const [key, value] of Object.entries(params)) { + resolved = resolved.replace(`:${key}`, encodeURIComponent(String(value))); + } + return resolved; +} + +/** + * Append query parameters to a URL. + */ +function appendQueryParams( + url: string, + queryParams: Record = {}, +): string { + if (Object.keys(queryParams).length === 0) return url; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams)) { + searchParams.set(key, String(value)); + } + return `${url}?${searchParams.toString()}`; +} + +type UrlParams = Record; +type QueryParams = Record; + +/** + * Create a plugin API that reads endpoints from runtime config. + * + * @param pluginName - Plugin name to look up in config + * @returns Proxy object with endpoint methods + * + * @example + * ```typescript + * const analytics = createPluginApi("analytics"); + * + * // Access named endpoint + * analytics.query({ query_key: "spend_data" }) + * // → "/api/analytics/query/spend_data" + * + * // With query params + * analytics.query({ query_key: "test" }, { dev: "tunnel-123" }) + * // → "/api/analytics/query/test?dev=tunnel-123" + * ``` + */ +export function createPluginApi(pluginName: string) { + return new Proxy( + {}, + { + get(_target, endpointName: string) { + return (params: UrlParams = {}, queryParams: QueryParams = {}) => { + const config = getConfig(); + const pluginEndpoints = config.endpoints[pluginName]; + + if (!pluginEndpoints) { + throw new Error( + `Plugin "${pluginName}" not found in endpoints config`, + ); + } + + const template = pluginEndpoints[endpointName]; + if (!template) { + throw new Error( + `Endpoint "${endpointName}" not found for plugin "${pluginName}"`, + ); + } + + const url = substituteParams(template, params); + return appendQueryParams(url, queryParams); + }; + }, + }, + ) as Record< + string, + (params?: UrlParams, queryParams?: QueryParams) => string + >; +} + +/** + * Build a URL directly from a path template. + * + * @example + * ```typescript + * buildUrl("/api/analytics/query/:query_key", { query_key: "spend_data" }) + * // → "/api/analytics/query/spend_data" + * ``` + */ +export function buildUrl( + template: string, + params: UrlParams = {}, + queryParams: QueryParams = {}, +): string { + const url = substituteParams(template, params); + return appendQueryParams(url, queryParams); +} + +/** Base endpoint function type */ +export type EndpointFn = ( + params?: TParams, + queryParams?: QueryParams, +) => string; + +/** Default plugin API shape (all endpoints accept any params) */ +export type DefaultPluginApi = Record; + +/** + * Augmentable interface for typed plugin APIs. + * + * Apps can extend this interface to get type-safe endpoint access. + * + * @example + * ```typescript + * // In your app's appKitTypes.d.ts: + * declare module '@databricks/app-kit-ui' { + * interface AppKitPlugins { + * analytics: { + * query: EndpointFn<{ query_key: string }>; + * arrowResult: EndpointFn<{ jobId: string }>; + * }; + * reconnect: { + * status: EndpointFn; + * stream: EndpointFn; + * }; + * } + * } + * ``` + */ +// biome-ignore lint/suspicious/noEmptyInterface: Designed for module augmentation +export interface AppKitPlugins {} + +/** Resolved API type - uses augmented types if available, otherwise defaults */ +type ApiType = AppKitPlugins & Record; + +/** + * Dynamic API helper that reads plugins from runtime config. + * + * Automatically synced with the plugins registered on the server. + * Access any plugin's named endpoints directly. + * + * For type safety, augment the `AppKitPlugins` interface in your app. + * + * @example + * ```typescript + * // Access any plugin's endpoints (auto-discovered from server config) + * api.analytics.query({ query_key: "spend_data" }) + * // → "/api/analytics/query/spend_data" + * + * api.analytics.arrowResult({ jobId: "abc123" }) + * // → "/api/analytics/arrow-result/abc123" + * + * api.reconnect.stream() + * // → "/api/reconnect/stream" + * + * // Works with any plugin registered on the server + * api.myCustomPlugin.myEndpoint({ id: "123" }) + * ``` + */ +export const api: ApiType = new Proxy({} as ApiType, { + get(_target, pluginName: string) { + return createPluginApi(pluginName); + }, +}); + +// ============================================================================ +// Pre-typed Plugin APIs for internal package use +// ============================================================================ +// These helpers provide type-safe endpoint access within app-kit-ui itself, +// since the AppKitPlugins augmentation only applies in consuming apps. +// AnalyticsEndpointParams is imported from shared package (single source of truth). + +/** Typed analytics API for internal package use */ +export interface AnalyticsApiType { + query: ( + params: AnalyticsEndpointParams["query"], + queryParams?: QueryParams, + ) => string; + queryAsUser: ( + params: AnalyticsEndpointParams["queryAsUser"], + queryParams?: QueryParams, + ) => string; + arrowResult: ( + params: AnalyticsEndpointParams["arrowResult"], + queryParams?: QueryParams, + ) => string; +} + +/** + * Pre-typed analytics API for use within the app-kit-ui package. + * + * This provides type-safe access to analytics endpoints without relying + * on AppKitPlugins augmentation (which only works in consuming apps). + * + * @example + * ```typescript + * // Type-safe within the package + * analyticsApi.query({ query_key: "spend_data" }) + * // → "/api/analytics/query/spend_data" + * + * analyticsApi.arrowResult({ jobId: "abc123" }) + * // → "/api/analytics/arrow-result/abc123" + * ``` + */ +export const analyticsApi: AnalyticsApiType = createPluginApi( + "analytics", +) as unknown as AnalyticsApiType; diff --git a/packages/app-kit-ui/src/js/index.ts b/packages/app-kit-ui/src/js/index.ts index 4350045..25c3e06 100644 --- a/packages/app-kit-ui/src/js/index.ts +++ b/packages/app-kit-ui/src/js/index.ts @@ -1,5 +1,6 @@ export { isSQLTypeMarker, + type PathParams, type SQLBinaryMarker, type SQLBooleanMarker, type SQLDateMarker, @@ -11,4 +12,5 @@ export { } from "shared"; export * from "./arrow"; export * from "./constants"; +export * from "./endpoints"; export * from "./sse"; diff --git a/packages/app-kit-ui/src/react/hooks/use-analytics-query.ts b/packages/app-kit-ui/src/react/hooks/use-analytics-query.ts index 72f09e1..b0a82b9 100644 --- a/packages/app-kit-ui/src/react/hooks/use-analytics-query.ts +++ b/packages/app-kit-ui/src/react/hooks/use-analytics-query.ts @@ -1,4 +1,4 @@ -import { ArrowClient, connectSSE } from "@/js"; +import { analyticsApi, ArrowClient, connectSSE } from "@/js"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnalyticsFormat, @@ -10,16 +10,10 @@ import type { } from "./types"; import { useQueryHMR } from "./use-query-hmr"; -function getDevMode() { +function getDevModeParams(): Record { const url = new URL(window.location.href); - const searchParams = url.searchParams; - const dev = searchParams.get("dev"); - - return dev ? `?dev=${dev}` : ""; -} - -function getArrowStreamUrl(id: string) { - return `/api/analytics/arrow-result/${id}`; + const dev = url.searchParams.get("dev"); + return dev ? { dev } : {}; } /** @@ -107,10 +101,10 @@ export function useAnalyticsQuery< const abortController = new AbortController(); abortControllerRef.current = abortController; - const devMode = getDevMode(); + const url = analyticsApi.query({ query_key: queryKey }, getDevModeParams()); connectSSE({ - url: `/api/analytics/query/${encodeURIComponent(queryKey)}${devMode}`, + url, payload: payload, signal: abortController.signal, onMessage: async (message) => { @@ -128,7 +122,9 @@ export function useAnalyticsQuery< if (parsed.type === "arrow") { try { const arrowData = await ArrowClient.fetchArrow( - getArrowStreamUrl(parsed.statement_id), + analyticsApi.arrowResult({ + jobId: parsed.statement_id, + }), ); const table = await ArrowClient.processArrowBuffer(arrowData); setLoading(false); diff --git a/packages/app-kit/src/analytics/analytics.ts b/packages/app-kit/src/analytics/analytics.ts index 81d4b12..b6b8d4d 100644 --- a/packages/app-kit/src/analytics/analytics.ts +++ b/packages/app-kit/src/analytics/analytics.ts @@ -1,9 +1,10 @@ import type { WorkspaceClient } from "@databricks/sdk-experimental"; -import type { - IAppRouter, - PluginExecuteConfig, - SQLTypeMarker, - StreamExecutionSettings, +import { + analyticsRoutes, + type IAppRouter, + type PluginExecuteConfig, + type SQLTypeMarker, + type StreamExecutionSettings, } from "shared"; import { SQLWarehouseConnector } from "../connectors"; import { Plugin, toPlugin } from "../plugin"; @@ -62,7 +63,7 @@ export class AnalyticsPlugin extends Plugin { this.route(router, { name: "queryAsUser", method: "post", - path: "/users/me/query/:query_key", + path: analyticsRoutes.queryAsUser, handler: async (req: Request, res: Response) => { await this._handleQueryRoute(req, res, { asUser: true }); }, @@ -71,7 +72,7 @@ export class AnalyticsPlugin extends Plugin { this.route(router, { name: "query", method: "post", - path: "/query/:query_key", + path: analyticsRoutes.query, handler: async (req: Request, res: Response) => { await this._handleQueryRoute(req, res, { asUser: false }); }, diff --git a/packages/app-kit/src/server/index.ts b/packages/app-kit/src/server/index.ts index c786bec..808b5b7 100644 --- a/packages/app-kit/src/server/index.ts +++ b/packages/app-kit/src/server/index.ts @@ -44,6 +44,7 @@ export class ServerPlugin extends Plugin { private remoteTunnelController?: RemoteTunnelController; protected declare config: ServerConfig; private serverExtensions: ((app: express.Application) => void)[] = []; + private pluginEndpoints: PluginEndpoints = {}; static phase: PluginPhase = "deferred"; constructor(config: ServerConfig) { @@ -177,6 +178,15 @@ export class ServerPlugin extends Plugin { }); this.registerEndpoint("health", "/health"); + // Schema endpoint for type generation + // TODO: This should only be on development mode + this.serverApplication.get("/__schema__", (_, res) => { + console.log(`[AppKit] Serving schema:`, this.pluginEndpoints); + res.json({ + endpoints: this.pluginEndpoints, + }); + }); + for (const plugin of Object.values(this.config.plugins)) { if (EXCLUDED_PLUGINS.includes(plugin.name)) continue; diff --git a/packages/app-kit/src/type-generator/index.ts b/packages/app-kit/src/type-generator/index.ts index a6d07c6..d2cc71d 100644 --- a/packages/app-kit/src/type-generator/index.ts +++ b/packages/app-kit/src/type-generator/index.ts @@ -5,13 +5,73 @@ import type { QuerySchema } from "./types"; dotenv.config(); +/** Plugin endpoints schema from server */ +interface PluginEndpoints { + [pluginName: string]: { + [endpointName: string]: string; + }; +} + +/** + * Extract path parameters from a URL template. + * e.g., "/api/analytics/query/:query_key" -> ["query_key"] + */ +function extractPathParams(template: string): string[] { + const matches = template.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g); + return matches ? matches.map((m) => m.slice(1)) : []; +} + /** - * Generate type declarations for QueryRegistry + * Generate AppKitPlugins type declarations from endpoint schema. + */ +function generatePluginTypes(endpoints: PluginEndpoints): string { + const pluginEntries = Object.entries(endpoints) + .filter(([, pluginEndpoints]) => Object.keys(pluginEndpoints).length > 0) + .map(([pluginName, pluginEndpoints]) => { + const endpointEntries = Object.entries(pluginEndpoints) + .map(([endpointName, template]) => { + const params = extractPathParams(template); + if (params.length === 0) { + return ` ${endpointName}: EndpointFn;`; + } + const paramType = `{ ${params + .map((p) => `${p}: string`) + .join("; ")} }`; + return ` ${endpointName}: EndpointFn<${paramType}>;`; + }) + .join("\n"); + + // Handle plugin names with hyphens by quoting them + const quotedName = pluginName.includes("-") + ? `"${pluginName}"` + : pluginName; + + return ` ${quotedName}: {\n${endpointEntries}\n };`; + }) + .join("\n"); + + if (!pluginEntries) return ""; + + return ` +declare module "@databricks/app-kit-ui/js" { + interface AppKitPlugins { +${pluginEntries} + } +} +`; +} + +/** + * Generate type declarations for QueryRegistry and AppKitPlugins * Create the d.ts file from the plugin routes and query schemas * @param querySchemas - the list of query schemas + * @param endpoints - the plugin endpoints schema * @returns - the type declarations as a string */ -function generateTypeDeclarations(querySchemas: QuerySchema[] = []): string { +function generateTypeDeclarations( + querySchemas: QuerySchema[] = [], + endpoints: PluginEndpoints = {}, +): string { const queryEntries = querySchemas .map(({ name, type }) => { const indentedType = type @@ -23,36 +83,85 @@ function generateTypeDeclarations(querySchemas: QuerySchema[] = []): string { .join(";\n"); const querySection = queryEntries ? `\n${queryEntries};\n ` : ""; + const pluginTypesSection = generatePluginTypes(endpoints); return `// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx appkit-generate-types' or Vite plugin during build import "@databricks/app-kit-ui/react"; -import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker } from "@databricks/app-kit-ui/js"; - +import "@databricks/app-kit-ui/js"; +import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker, EndpointFn } from "@databricks/app-kit-ui/js"; +${pluginTypesSection} declare module "@databricks/app-kit-ui/react" { interface QueryRegistry {${querySection}} } `; } +/** + * Fetch the schema from a running server. + * @param serverUrl - the server URL (e.g., http://localhost:8000) + * @param timeoutMs - timeout in milliseconds (default 5000) + * @returns the endpoints schema or empty object if unavailable + */ +async function fetchServerSchema( + serverUrl: string, + timeoutMs = 5000, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + console.log(`[AppKit] Fetching schema from ${serverUrl}/__schema__`); + const response = await fetch(`${serverUrl}/__schema__`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.warn( + `[AppKit] Could not fetch schema from ${serverUrl}/__schema__ (${response.status})`, + ); + return {}; + } + const data = (await response.json()) as { endpoints?: PluginEndpoints }; + console.log(`[AppKit] Schema fetched successfully`); + return data.endpoints || {}; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + console.warn(`[AppKit] Timeout fetching schema from ${serverUrl}`); + } else { + console.warn( + `[AppKit] Could not connect to server at ${serverUrl}:`, + error instanceof Error ? error.message : error, + ); + } + return {}; + } +} + /** * Entry point for generating type declarations from all imported files * @param options - the options for the generation * @param options.entryPoint - the entry point file * @param options.outFile - the output file * @param options.querySchemaFile - optional path to query schema file (e.g. config/queries/schema.ts) + * @param options.serverUrl - optional server URL to fetch plugin endpoints from */ export async function generateFromEntryPoint(options: { outFile: string; queryFolder?: string; warehouseId: string; noCache?: boolean; + serverUrl?: string; }) { - const { outFile, queryFolder, warehouseId, noCache } = options; + const { outFile, queryFolder, warehouseId, noCache, serverUrl } = options; console.log("\n[AppKit] Starting type generation...\n"); let queryRegistry: QuerySchema[] = []; + if (queryFolder) queryRegistry = await generateQueriesFromDescribe( queryFolder, @@ -62,7 +171,17 @@ export async function generateFromEntryPoint(options: { }, ); - const typeDeclarations = generateTypeDeclarations(queryRegistry); + // Fetch plugin endpoints from server if URL provided + let endpoints: PluginEndpoints = {}; + if (serverUrl) { + console.log(`[AppKit] Fetching plugin schema from ${serverUrl}...`); + + endpoints = await fetchServerSchema(serverUrl); + const pluginCount = Object.keys(endpoints).length; + console.log(`[AppKit] Found ${pluginCount} plugins with endpoints`); + } + + const typeDeclarations = generateTypeDeclarations(queryRegistry, endpoints); fs.writeFileSync(outFile, typeDeclarations, "utf-8"); diff --git a/packages/app-kit/src/type-generator/vite-plugin.ts b/packages/app-kit/src/type-generator/vite-plugin.ts index b23f902..43ce86c 100644 --- a/packages/app-kit/src/type-generator/vite-plugin.ts +++ b/packages/app-kit/src/type-generator/vite-plugin.ts @@ -1,35 +1,51 @@ -import { execSync } from "node:child_process"; import path from "node:path"; import type { Plugin } from "vite"; +import { generateFromEntryPoint } from "./index"; /** * Options for the AppKit types plugin. */ interface AppKitTypesPluginOptions { - /* Path to the output d.ts file (relative to client folder). */ + /** Path to the output d.ts file (relative to client folder). */ outFile?: string; /** Folders to watch for changes. */ watchFolders?: string[]; + /** + * Server URL to fetch plugin endpoints from. + * Used to generate typed API client. + * @default "http://localhost:8000" in development + */ + serverUrl?: string; } +const DEFAULT_SERVER_URL = "http://localhost:8000"; + /** - * Vite plugin to generate types for AppKit queries. - * Calls `npx appkit-generate-types` under the hood. + * Vite plugin to generate types for AppKit queries and plugin endpoints. + * + * Features: + * - Generates QueryRegistry types from SQL files + * - Generates AppKitPlugins types from server plugin endpoints + * - Watches for SQL file changes and regenerates + * - In dev mode, fetches plugin schema after server starts + * * @param options - Options to override default values. * @returns Vite plugin to generate types for AppKit queries. */ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { let root: string; - let appRoot: string; let outFile: string; let watchFolders: string[]; + let serverUrl: string; - function generate() { + async function generate() { try { - const args = [appRoot, outFile].join(" "); - execSync(`npx appkit-generate-types ${args}`, { - cwd: appRoot, - stdio: "inherit", + await generateFromEntryPoint({ + outFile, + queryFolder: watchFolders[0], + warehouseId: process.env.DATABRICKS_WAREHOUSE_ID || "", + serverUrl, + noCache: false, }); } catch (error) { // throw in production to fail the build @@ -42,16 +58,21 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { return { name: "appkit-types", + configResolved(config) { root = config.root; - appRoot = path.resolve(root, ".."); outFile = path.resolve(root, options?.outFile ?? "src/appKitTypes.d.ts"); + serverUrl = + options?.serverUrl || + process.env.APPKIT_SERVER_URL || + DEFAULT_SERVER_URL; watchFolders = (options?.watchFolders ?? ["../config/queries"]).map( (folder) => path.resolve(root, folder), ); }, + buildStart() { generate(); }, @@ -68,6 +89,10 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { generate(); } }); + + server.watcher.on("ready", async () => { + generate(); + }); }, }; } diff --git a/packages/shared/src/endpoints/analytics.ts b/packages/shared/src/endpoints/analytics.ts new file mode 100644 index 0000000..1a57cde --- /dev/null +++ b/packages/shared/src/endpoints/analytics.ts @@ -0,0 +1,72 @@ +/** + * Shared analytics plugin route definitions. + * + * Single source of truth for analytics endpoints used by both: + * - app-kit (server-side AnalyticsPlugin) + * - app-kit-ui (client-side analyticsApi) + */ + +// ============================================================================ +// Route Definitions +// ============================================================================ + +/** + * Analytics plugin route paths. + * These are relative to the plugin base path (/api/analytics/). + */ +export const analyticsRoutes = { + query: "/query/:query_key", + queryAsUser: "/users/me/query/:query_key", + arrowResult: "/arrow-result/:jobId", +} as const; + +// ============================================================================ +// Type Utilities for Path Parameter Extraction +// ============================================================================ + +/** + * Extract all path parameter names from a URL template. + * Handles both mid-path and end-of-path parameters. + * + * e.g., "/users/:userId/posts/:postId" -> "userId" | "postId" + */ +type ExtractPathParams = + T extends `${string}:${infer Param}/${infer Rest}` + ? Param | ExtractPathParams<`/${Rest}`> + : T extends `${string}:${infer Param}` + ? Param + : never; + +/** + * Convert a union of parameter names to an object type with string values. + * e.g., "userId" | "postId" -> { userId: string; postId: string } + */ +type ParamsToObject = [T] extends [never] + ? Record + : { [K in T]: string }; + +/** + * Extract path parameters from a URL template as an object type. + * e.g., "/query/:query_key" -> { query_key: string } + */ +export type PathParams = ParamsToObject>; + +// ============================================================================ +// Analytics Endpoint Parameter Types (derived from routes) +// ============================================================================ + +/** + * Type-safe parameter definitions for each analytics endpoint. + * Automatically derived from the route path templates. + */ +export type AnalyticsEndpointParams = { + [K in keyof typeof analyticsRoutes]: PathParams<(typeof analyticsRoutes)[K]>; +}; + +// Verify the types are correct (these are compile-time checks) +// AnalyticsEndpointParams should be: +// { +// query: { query_key: string }; +// queryAsUser: { query_key: string }; +// arrowResult: { jobId: string }; +// } diff --git a/packages/shared/src/endpoints/index.ts b/packages/shared/src/endpoints/index.ts new file mode 100644 index 0000000..0ddf5b5 --- /dev/null +++ b/packages/shared/src/endpoints/index.ts @@ -0,0 +1,5 @@ +export { + analyticsRoutes, + type AnalyticsEndpointParams, + type PathParams, +} from "./analytics"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4f09a67..5690cae 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./cache"; +export * from "./endpoints"; export * from "./execute"; export * from "./plugin"; export * from "./sql";