Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/dev-playground/client/src/appKitTypes.d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
258 changes: 258 additions & 0 deletions packages/app-kit-ui/src/js/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/** Map of plugin names to their endpoint maps */
export type PluginEndpoints = Record<string, PluginEndpointMap>;

export interface RuntimeConfig {
appName: string;
queries: Record<string, string>;
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, string | number> = {},
): 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, string | number | boolean> = {},
): 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<string, string | number>;
type QueryParams = Record<string, string | number | boolean>;

/**
* 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<TParams = UrlParams> = (
params?: TParams,
queryParams?: QueryParams,
) => string;

/** Default plugin API shape (all endpoints accept any params) */
export type DefaultPluginApi = Record<string, EndpointFn>;

/**
* 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<string, DefaultPluginApi>;

/**
* 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;
2 changes: 2 additions & 0 deletions packages/app-kit-ui/src/js/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
isSQLTypeMarker,
type PathParams,
type SQLBinaryMarker,
type SQLBooleanMarker,
type SQLDateMarker,
Expand All @@ -11,4 +12,5 @@ export {
} from "shared";
export * from "./arrow";
export * from "./constants";
export * from "./endpoints";
export * from "./sse";
22 changes: 9 additions & 13 deletions packages/app-kit-ui/src/react/hooks/use-analytics-query.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,16 +10,10 @@ import type {
} from "./types";
import { useQueryHMR } from "./use-query-hmr";

function getDevMode() {
function getDevModeParams(): Record<string, string> {
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 } : {};
}

/**
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
15 changes: 8 additions & 7 deletions packages/app-kit/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -62,7 +63,7 @@ export class AnalyticsPlugin extends Plugin {
this.route<AnalyticsQueryResponse>(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 });
},
Expand All @@ -71,7 +72,7 @@ export class AnalyticsPlugin extends Plugin {
this.route<AnalyticsQueryResponse>(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 });
},
Expand Down
Loading