Skip to content

Commit e7febcb

Browse files
committed
feat: config and typed endpoints
1 parent 2e792d2 commit e7febcb

File tree

11 files changed

+545
-38
lines changed

11 files changed

+545
-38
lines changed

apps/dev-playground/client/src/appKitTypes.d.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
// Auto-generated by AppKit - DO NOT EDIT
22
// Generated by 'npx appkit-generate-types' or Vite plugin during build
33
import "@databricks/app-kit-ui/react";
4-
import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker } from "@databricks/app-kit-ui/js";
4+
import "@databricks/app-kit-ui/js";
5+
import type { SQLTypeMarker, SQLStringMarker, SQLNumberMarker, SQLBooleanMarker, SQLBinaryMarker, SQLDateMarker, SQLTimestampMarker, EndpointFn } from "@databricks/app-kit-ui/js";
6+
7+
declare module "@databricks/app-kit-ui/js" {
8+
interface AppKitPlugins {
9+
reconnect: {
10+
status: EndpointFn;
11+
stream: EndpointFn;
12+
};
13+
"telemetry-examples": {
14+
combined: EndpointFn;
15+
};
16+
analytics: {
17+
arrowResult: EndpointFn<{ jobId: string }>;
18+
queryAsUser: EndpointFn<{ query_key: string }>;
19+
query: EndpointFn<{ query_key: string }>;
20+
};
21+
}
22+
}
523

624
declare module "@databricks/app-kit-ui/react" {
725
interface QueryRegistry {
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* Endpoints utility for accessing backend API routes.
3+
*
4+
* Provides a clean way to build API URLs with parameter substitution,
5+
* reading from the runtime config injected by the server.
6+
*/
7+
8+
import type { AnalyticsEndpointParams } from "shared";
9+
10+
// Re-export for consumers
11+
export type { AnalyticsEndpointParams } from "shared";
12+
13+
/** Map of endpoint names to their path templates for a plugin */
14+
export type PluginEndpointMap = Record<string, string>;
15+
16+
/** Map of plugin names to their endpoint maps */
17+
export type PluginEndpoints = Record<string, PluginEndpointMap>;
18+
19+
export interface RuntimeConfig {
20+
appName: string;
21+
queries: Record<string, string>;
22+
endpoints: PluginEndpoints;
23+
}
24+
25+
declare global {
26+
interface Window {
27+
__CONFIG__?: RuntimeConfig;
28+
}
29+
}
30+
31+
/**
32+
* Get the runtime config from the window object.
33+
*/
34+
export function getConfig(): RuntimeConfig {
35+
if (!window.__CONFIG__) {
36+
throw new Error(
37+
"Runtime config not found. Make sure the server is injecting __CONFIG__.",
38+
);
39+
}
40+
return window.__CONFIG__;
41+
}
42+
43+
/**
44+
* Substitute path parameters in a URL template.
45+
*
46+
* @param template - URL template with :param placeholders
47+
* @param params - Parameters to substitute
48+
* @returns The resolved URL
49+
*/
50+
function substituteParams(
51+
template: string,
52+
params: Record<string, string | number> = {},
53+
): string {
54+
let resolved = template;
55+
for (const [key, value] of Object.entries(params)) {
56+
resolved = resolved.replace(`:${key}`, encodeURIComponent(String(value)));
57+
}
58+
return resolved;
59+
}
60+
61+
/**
62+
* Append query parameters to a URL.
63+
*/
64+
function appendQueryParams(
65+
url: string,
66+
queryParams: Record<string, string | number | boolean> = {},
67+
): string {
68+
if (Object.keys(queryParams).length === 0) return url;
69+
70+
const searchParams = new URLSearchParams();
71+
for (const [key, value] of Object.entries(queryParams)) {
72+
searchParams.set(key, String(value));
73+
}
74+
return `${url}?${searchParams.toString()}`;
75+
}
76+
77+
type UrlParams = Record<string, string | number>;
78+
type QueryParams = Record<string, string | number | boolean>;
79+
80+
/**
81+
* Create a plugin API that reads endpoints from runtime config.
82+
*
83+
* @param pluginName - Plugin name to look up in config
84+
* @returns Proxy object with endpoint methods
85+
*
86+
* @example
87+
* ```typescript
88+
* const analytics = createPluginApi("analytics");
89+
*
90+
* // Access named endpoint
91+
* analytics.query({ query_key: "spend_data" })
92+
* // → "/api/analytics/query/spend_data"
93+
*
94+
* // With query params
95+
* analytics.query({ query_key: "test" }, { dev: "tunnel-123" })
96+
* // → "/api/analytics/query/test?dev=tunnel-123"
97+
* ```
98+
*/
99+
export function createPluginApi(pluginName: string) {
100+
return new Proxy(
101+
{},
102+
{
103+
get(_target, endpointName: string) {
104+
return (params: UrlParams = {}, queryParams: QueryParams = {}) => {
105+
const config = getConfig();
106+
const pluginEndpoints = config.endpoints[pluginName];
107+
108+
if (!pluginEndpoints) {
109+
throw new Error(
110+
`Plugin "${pluginName}" not found in endpoints config`,
111+
);
112+
}
113+
114+
const template = pluginEndpoints[endpointName];
115+
if (!template) {
116+
throw new Error(
117+
`Endpoint "${endpointName}" not found for plugin "${pluginName}"`,
118+
);
119+
}
120+
121+
const url = substituteParams(template, params);
122+
return appendQueryParams(url, queryParams);
123+
};
124+
},
125+
},
126+
) as Record<
127+
string,
128+
(params?: UrlParams, queryParams?: QueryParams) => string
129+
>;
130+
}
131+
132+
/**
133+
* Build a URL directly from a path template.
134+
*
135+
* @example
136+
* ```typescript
137+
* buildUrl("/api/analytics/query/:query_key", { query_key: "spend_data" })
138+
* // → "/api/analytics/query/spend_data"
139+
* ```
140+
*/
141+
export function buildUrl(
142+
template: string,
143+
params: UrlParams = {},
144+
queryParams: QueryParams = {},
145+
): string {
146+
const url = substituteParams(template, params);
147+
return appendQueryParams(url, queryParams);
148+
}
149+
150+
/** Base endpoint function type */
151+
export type EndpointFn<TParams = UrlParams> = (
152+
params?: TParams,
153+
queryParams?: QueryParams,
154+
) => string;
155+
156+
/** Default plugin API shape (all endpoints accept any params) */
157+
export type DefaultPluginApi = Record<string, EndpointFn>;
158+
159+
/**
160+
* Augmentable interface for typed plugin APIs.
161+
*
162+
* Apps can extend this interface to get type-safe endpoint access.
163+
*
164+
* @example
165+
* ```typescript
166+
* // In your app's appKitTypes.d.ts:
167+
* declare module '@databricks/app-kit-ui' {
168+
* interface AppKitPlugins {
169+
* analytics: {
170+
* query: EndpointFn<{ query_key: string }>;
171+
* arrowResult: EndpointFn<{ jobId: string }>;
172+
* };
173+
* reconnect: {
174+
* status: EndpointFn;
175+
* stream: EndpointFn;
176+
* };
177+
* }
178+
* }
179+
* ```
180+
*/
181+
// biome-ignore lint/suspicious/noEmptyInterface: Designed for module augmentation
182+
export interface AppKitPlugins {}
183+
184+
/** Resolved API type - uses augmented types if available, otherwise defaults */
185+
type ApiType = AppKitPlugins & Record<string, DefaultPluginApi>;
186+
187+
/**
188+
* Dynamic API helper that reads plugins from runtime config.
189+
*
190+
* Automatically synced with the plugins registered on the server.
191+
* Access any plugin's named endpoints directly.
192+
*
193+
* For type safety, augment the `AppKitPlugins` interface in your app.
194+
*
195+
* @example
196+
* ```typescript
197+
* // Access any plugin's endpoints (auto-discovered from server config)
198+
* api.analytics.query({ query_key: "spend_data" })
199+
* // → "/api/analytics/query/spend_data"
200+
*
201+
* api.analytics.arrowResult({ jobId: "abc123" })
202+
* // → "/api/analytics/arrow-result/abc123"
203+
*
204+
* api.reconnect.stream()
205+
* // → "/api/reconnect/stream"
206+
*
207+
* // Works with any plugin registered on the server
208+
* api.myCustomPlugin.myEndpoint({ id: "123" })
209+
* ```
210+
*/
211+
export const api: ApiType = new Proxy({} as ApiType, {
212+
get(_target, pluginName: string) {
213+
return createPluginApi(pluginName);
214+
},
215+
});
216+
217+
// ============================================================================
218+
// Pre-typed Plugin APIs for internal package use
219+
// ============================================================================
220+
// These helpers provide type-safe endpoint access within app-kit-ui itself,
221+
// since the AppKitPlugins augmentation only applies in consuming apps.
222+
// AnalyticsEndpointParams is imported from shared package (single source of truth).
223+
224+
/** Typed analytics API for internal package use */
225+
export interface AnalyticsApiType {
226+
query: (
227+
params: AnalyticsEndpointParams["query"],
228+
queryParams?: QueryParams,
229+
) => string;
230+
queryAsUser: (
231+
params: AnalyticsEndpointParams["queryAsUser"],
232+
queryParams?: QueryParams,
233+
) => string;
234+
arrowResult: (
235+
params: AnalyticsEndpointParams["arrowResult"],
236+
queryParams?: QueryParams,
237+
) => string;
238+
}
239+
240+
/**
241+
* Pre-typed analytics API for use within the app-kit-ui package.
242+
*
243+
* This provides type-safe access to analytics endpoints without relying
244+
* on AppKitPlugins augmentation (which only works in consuming apps).
245+
*
246+
* @example
247+
* ```typescript
248+
* // Type-safe within the package
249+
* analyticsApi.query({ query_key: "spend_data" })
250+
* // → "/api/analytics/query/spend_data"
251+
*
252+
* analyticsApi.arrowResult({ jobId: "abc123" })
253+
* // → "/api/analytics/arrow-result/abc123"
254+
* ```
255+
*/
256+
export const analyticsApi: AnalyticsApiType = createPluginApi(
257+
"analytics",
258+
) as unknown as AnalyticsApiType;

packages/app-kit-ui/src/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
isSQLTypeMarker,
3+
type PathParams,
34
type SQLBinaryMarker,
45
type SQLBooleanMarker,
56
type SQLDateMarker,
@@ -11,4 +12,5 @@ export {
1112
} from "shared";
1213
export * from "./arrow";
1314
export * from "./constants";
15+
export * from "./endpoints";
1416
export * from "./sse";

packages/app-kit-ui/src/react/hooks/use-analytics-query.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowClient, connectSSE } from "@/js";
1+
import { analyticsApi, ArrowClient, connectSSE } from "@/js";
22
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import type {
44
AnalyticsFormat,
@@ -10,16 +10,10 @@ import type {
1010
} from "./types";
1111
import { useQueryHMR } from "./use-query-hmr";
1212

13-
function getDevMode() {
13+
function getDevModeParams(): Record<string, string> {
1414
const url = new URL(window.location.href);
15-
const searchParams = url.searchParams;
16-
const dev = searchParams.get("dev");
17-
18-
return dev ? `?dev=${dev}` : "";
19-
}
20-
21-
function getArrowStreamUrl(id: string) {
22-
return `/api/analytics/arrow-result/${id}`;
15+
const dev = url.searchParams.get("dev");
16+
return dev ? { dev } : {};
2317
}
2418

2519
/**
@@ -107,10 +101,10 @@ export function useAnalyticsQuery<
107101
const abortController = new AbortController();
108102
abortControllerRef.current = abortController;
109103

110-
const devMode = getDevMode();
104+
const url = analyticsApi.query({ query_key: queryKey }, getDevModeParams());
111105

112106
connectSSE({
113-
url: `/api/analytics/query/${encodeURIComponent(queryKey)}${devMode}`,
107+
url,
114108
payload: payload,
115109
signal: abortController.signal,
116110
onMessage: async (message) => {
@@ -128,7 +122,9 @@ export function useAnalyticsQuery<
128122
if (parsed.type === "arrow") {
129123
try {
130124
const arrowData = await ArrowClient.fetchArrow(
131-
getArrowStreamUrl(parsed.statement_id),
125+
analyticsApi.arrowResult({
126+
jobId: parsed.statement_id,
127+
}),
132128
);
133129
const table = await ArrowClient.processArrowBuffer(arrowData);
134130
setLoading(false);

packages/app-kit/src/analytics/analytics.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { WorkspaceClient } from "@databricks/sdk-experimental";
2-
import type {
3-
IAppRouter,
4-
PluginExecuteConfig,
5-
SQLTypeMarker,
6-
StreamExecutionSettings,
2+
import {
3+
analyticsRoutes,
4+
type IAppRouter,
5+
type PluginExecuteConfig,
6+
type SQLTypeMarker,
7+
type StreamExecutionSettings,
78
} from "shared";
89
import { SQLWarehouseConnector } from "../connectors";
910
import { Plugin, toPlugin } from "../plugin";
@@ -62,7 +63,7 @@ export class AnalyticsPlugin extends Plugin {
6263
this.route<AnalyticsQueryResponse>(router, {
6364
name: "queryAsUser",
6465
method: "post",
65-
path: "/users/me/query/:query_key",
66+
path: analyticsRoutes.queryAsUser,
6667
handler: async (req: Request, res: Response) => {
6768
await this._handleQueryRoute(req, res, { asUser: true });
6869
},
@@ -71,7 +72,7 @@ export class AnalyticsPlugin extends Plugin {
7172
this.route<AnalyticsQueryResponse>(router, {
7273
name: "query",
7374
method: "post",
74-
path: "/query/:query_key",
75+
path: analyticsRoutes.query,
7576
handler: async (req: Request, res: Response) => {
7677
await this._handleQueryRoute(req, res, { asUser: false });
7778
},

0 commit comments

Comments
 (0)