Skip to content
Merged
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
17 changes: 10 additions & 7 deletions apps/dev-playground/server/testing-kit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,25 @@ const filesPlugin = () =>
files({ volumes: { reports: { policy: files.policy.allowAll() } } });

describe("getMock — assert the workspace-client call a route made", () => {
test("GET /api/files/reports/metadata calls files.getMetadata", async () => {
// Metadata is a raw HEAD (the typed call drops the headers), so seed `request`.
test("GET /api/files/reports/metadata issues a HEAD request", async () => {
await using app = await createTestApp({
plugins: [filesPlugin()],
env: FILES_ENV,
responses: {
"files.getMetadata": {
"content-length": "42",
"content-type": "text/plain",
},
request: () =>
new Response(null, {
headers: { "content-length": "42", "content-type": "text/plain" },
}),
},
});

const res = await app.get("/api/files/reports/metadata?path=report.csv");

expect(res.status).toBe(200);
expect(getMock(app.client, "files.getMetadata")).toHaveBeenCalled();
expect(getMock(app.client, "request")).toHaveBeenCalledWith(
expect.objectContaining({ method: "HEAD" }),
);
});
});

Expand Down Expand Up @@ -90,7 +93,7 @@ describe("createApiError — simulate a typed workspace error", () => {
plugins: [filesPlugin()],
env: FILES_ENV,
responses: {
"files.getMetadata": () => {
request: () => {
throw createApiError({
statusCode: 404,
message: "No such file",
Expand Down
23 changes: 20 additions & 3 deletions docs/docs/plugins/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,26 @@ The `list()` method with no arguments lists the volume root.
## Types

```ts
// Re-exported from @databricks/sdk-experimental
type DirectoryEntry = files.DirectoryEntry;
type DownloadResponse = files.DownloadResponse;
/** @deprecated Use FilesDirectoryEntry. Still returned by list() and /list. */
interface DirectoryEntry {
file_size?: number;
is_directory?: boolean;
last_modified?: number;
name?: string;
path?: string;
}

/** @deprecated Use FilesDownloadResponse. Still returned by download(). */
interface DownloadResponse {
"content-length"?: number;
"content-type"?: string;
contents?: ReadableStream;
"last-modified"?: string;
}

// Modular SDK shapes (camelCase; contentLength is bigint)
type FilesDirectoryEntry = { fileSize?: number; isDirectory?: boolean; lastModified?: number; name?: string; path?: string };
type FilesDownloadResponse = { contentLength?: bigint; contentType?: string; contents?: ReadableStream; lastModified?: string };

interface FileMetadata {
/** File size in bytes. */
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@databricks/sdk-auth",
"@databricks/sdk-core",
"@databricks/sdk-experimental",
"@databricks/sdk-files",
"@databricks/sdk-genie",
"@databricks/sdk-jobs",
"@databricks/sdk-options",
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@databricks/sdk-auth": "0.51.0",
"@databricks/sdk-core": "0.51.0",
"@databricks/sdk-experimental": "0.17.0",
"@databricks/sdk-files": "0.52.0",
"@databricks/sdk-genie": "0.54.0",
"@databricks/sdk-jobs": "0.57.0",
"@databricks/sdk-options": "0.51.0",
Expand Down
184 changes: 119 additions & 65 deletions packages/appkit/src/connectors/files/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,47 @@ export function runWithFilesSpanAttributes<T>(
return filesSpanAttributesStorage.run(attributes, fn);
}

/**
* The modular SDK throws `@databricks/sdk-core`'s `ApiError` (status on
* `httpStatusCode`, code on `.code`), a different class from the wrapper's
* `ApiError`. Convert it so `exists()` and the plugin's `instanceof ApiError`
* / `.statusCode` mapping keep returning upstream 4xx statuses.
*/
function toWrapperApiError(error: unknown): unknown {
if (
error instanceof Error &&
!(error instanceof ApiError) &&
"httpStatusCode" in error &&
typeof error.httpStatusCode === "number" &&
error.httpStatusCode > 0
) {
const code = "code" in error ? String(error.code) : "UNKNOWN";
return new ApiError(
error.message,
code,
error.httpStatusCode,
undefined,
[],
);
}
return error;
}

/** Stream a file, mapped back to the legacy `DownloadResponse` shape. */
async function downloadFile(
client: WorkspaceClient,
resolvedPath: string,
): Promise<DownloadResponse> {
const res = await client.files.downloadFile({ filePath: resolvedPath });
return {
"content-length":
res.contentLength === undefined ? undefined : Number(res.contentLength),
"content-type": res.contentType,
contents: res.contents,
"last-modified": res.lastModified,
};
}

interface FilesConnectorConfig {
defaultVolume?: string;
timeout?: number;
Expand Down Expand Up @@ -157,7 +198,8 @@ export class FilesConnector {
success = true;
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
} catch (thrown) {
const error = toWrapperApiError(thrown);
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
Expand Down Expand Up @@ -191,11 +233,20 @@ export class FilesConnector {
}

return this.traced("list", { "files.path": resolvedPath }, async () => {
// TODO: return the SDK's camelCase entries as-is once appkit-ui's file
// browser and the template's FilesPage read camelCase. Changing the
// `/list` JSON is breaking for app code reading `is_directory` etc.
const entries: DirectoryEntry[] = [];
for await (const entry of client.files.listDirectoryContents({
directory_path: resolvedPath,
for await (const entry of client.files.listDirectoryContentsIter({
directoryPath: resolvedPath,
})) {
entries.push(entry);
entries.push({
file_size: entry.fileSize,
is_directory: entry.isDirectory,
last_modified: entry.lastModified,
name: entry.name,
path: entry.path,
});
}
return entries;
});
Expand Down Expand Up @@ -239,11 +290,9 @@ export class FilesConnector {
filePath: string,
): Promise<DownloadResponse> {
const resolvedPath = this.resolvePath(filePath);
return this.traced("download", { "files.path": resolvedPath }, async () => {
return client.files.download({
file_path: resolvedPath,
});
});
return this.traced("download", { "files.path": resolvedPath }, async () =>
downloadFile(client, resolvedPath),
);
}

async exists(client: WorkspaceClient, filePath: string): Promise<boolean> {
Expand All @@ -267,17 +316,30 @@ export class FilesConnector {
): Promise<FileMetadata> {
const resolvedPath = this.resolvePath(filePath);
return this.traced("metadata", { "files.path": resolvedPath }, async () => {
const response = await client.files.getMetadata({
file_path: resolvedPath,
// The modular `getFileMetadata` parses the empty HEAD body and drops the
// response headers that carry the metadata, so read them off a raw HEAD.
// TODO: switch to `client.files.getFileMetadata` once sdk-files builds the
// response from the HEAD headers (as `downloadFile` already does).
// Encode each segment so `#`, `?` and `%` in a name aren't parsed as URL
// syntax by the raw seam (the typed SDK calls do the same).
const encodedPath = resolvedPath
.split("/")
.map(encodeURIComponent)
.join("/");
const res = await client.request({
method: "HEAD",
path: `/api/2.0/fs/files${encodedPath}`,
});
const contentLength = res.headers.get("content-length");
return {
contentLength: response["content-length"],
contentLength:
contentLength === null ? undefined : Number(contentLength),
contentType: contentTypeFromPath(
filePath,
response["content-type"],
res.headers.get("content-type") ?? undefined,
this.customContentTypes,
),
lastModified: response["last-modified"],
lastModified: res.headers.get("last-modified") ?? undefined,
};
});
}
Expand All @@ -286,57 +348,55 @@ export class FilesConnector {
client: WorkspaceClient,
filePath: string,
contents: ReadableStream | Buffer | string,
options?: { overwrite?: boolean },
options?: { overwrite?: boolean; signal?: AbortSignal },
): Promise<void> {
const resolvedPath = this.resolvePath(filePath);

return this.traced("upload", { "files.path": resolvedPath }, async () => {
const body = contents;
const overwrite = options?.overwrite ?? true;

// Workaround: The SDK's files.upload() has two bugs:
// 1. It ignores the `contents` field (sets body to undefined)
// 2. apiClient.request() checks `instanceof` against its own ReadableStream
// subclass, so standard ReadableStream instances get JSON.stringified to "{}"
// Bypass both by calling the REST API directly with SDK-provided auth.
const hostValue = client.config.host;
if (!hostValue) {
throw new Error(
"Databricks host is not configured. Set DATABRICKS_HOST or configure client.config.host.",
try {
if (contents instanceof ReadableStream) {
await client.files.uploadFile(
{ filePath: resolvedPath, contents, overwrite },
{ signal: options?.signal },
);
} else {
// `uploadFile` takes only a ReadableStream, which goes out chunked
// and can't be retried. Send in-memory contents as bytes instead, so
// the request carries Content-Length and the transport can retry it.
const encodedPath = resolvedPath
.split("/")
.map(encodeURIComponent)
.join("/");
const res = await client.request({
method: "PUT",
path: `/api/2.0/fs/files${encodedPath}`,
query: { overwrite: String(overwrite) },
headers: new Headers({
"Content-Type": "application/octet-stream",
}),
body:
typeof contents === "string"
? new TextEncoder().encode(contents)
: new Uint8Array(contents),
signal: options?.signal,
});
await res.body?.cancel();
}
} catch (e) {
const wrapped = toWrapperApiError(e);
if (!(wrapped instanceof ApiError)) throw e;
logger.error(
`Upload failed (${wrapped.statusCode}): ${wrapped.message}`,
);
}
const host = hostValue.startsWith("http")
? hostValue
: `https://${hostValue}`;
const url = new URL(`/api/2.0/fs/files${resolvedPath}`, host);
url.searchParams.set("overwrite", String(overwrite));

const headers = new Headers({
"Content-Type": "application/octet-stream",
"User-Agent": client.apiClient.userAgent(),
});
const fetchOptions: RequestInit = { method: "PUT", headers, body };

if (body instanceof ReadableStream) {
fetchOptions.duplex = "half";
} else if (body instanceof Buffer) {
headers.set("Content-Length", String(body.length));
} else if (typeof body === "string") {
headers.set("Content-Length", String(Buffer.byteLength(body)));
}

await client.config.authenticate(headers);

const res = await fetch(url.toString(), fetchOptions);

if (!res.ok) {
const text = await res.text();
logger.error(`Upload failed (${res.status}): ${text}`);
const safeMessage = text.length > 200 ? `${text.slice(0, 200)}…` : text;
const safeMessage =
wrapped.message.length > 200
? `${wrapped.message.slice(0, 200)}…`
: wrapped.message;
throw new ApiError(
`Upload failed: ${safeMessage}`,
"UPLOAD_FAILED",
res.status,
wrapped.statusCode,
undefined,
[],
);
Expand All @@ -353,19 +413,15 @@ export class FilesConnector {
"createDirectory",
{ "files.path": resolvedPath },
async () => {
await client.files.createDirectory({
directory_path: resolvedPath,
});
await client.files.createDirectory({ directoryPath: resolvedPath });
},
);
}

async delete(client: WorkspaceClient, filePath: string): Promise<void> {
const resolvedPath = this.resolvePath(filePath);
return this.traced("delete", { "files.path": resolvedPath }, async () => {
await client.files.delete({
file_path: resolvedPath,
});
await client.files.deleteFile({ filePath: resolvedPath });
});
}

Expand All @@ -384,9 +440,7 @@ export class FilesConnector {
return { ...meta, textPreview: null, isText: false, isImage };
}

const response = await client.files.download({
file_path: resolvedPath,
});
const response = await downloadFile(client, resolvedPath);
if (!response.contents) {
return { ...meta, textPreview: "", isText: true, isImage: false };
}
Expand Down
Loading
Loading