Files
kintone-customize-manager/src/main/kintone-api.ts
2026-03-12 15:59:54 +08:00

318 lines
8.7 KiB
TypeScript

import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import type { KintoneRestAPIError } from "@kintone/rest-api-client";
import type { DomainWithPassword } from "@shared/types/domain";
import type {
KintoneApp,
AppDetail,
FileContent,
AppCustomizationConfig,
KintoneApiError,
JSFileConfig,
CSSFileConfig,
} from "@shared/types/kintone";
/**
* Custom error class for Kintone API errors
*/
export class KintoneError extends Error {
public readonly code?: string;
public readonly id?: string;
public readonly statusCode?: number;
constructor(
message: string,
apiError?: KintoneApiError,
statusCode?: number,
) {
super(message);
this.name = "KintoneError";
this.code = apiError?.code;
this.id = apiError?.id;
this.statusCode = statusCode;
}
}
// Use typeof to get SDK method return types
type KintoneClient = KintoneRestAPIClient;
type AppResponse = ReturnType<KintoneClient["app"]["getApp"]>;
type AppsResponse = ReturnType<KintoneClient["app"]["getApps"]>;
type AppCustomizeResponse = ReturnType<KintoneClient["app"]["getAppCustomize"]>;
/**
* Kintone REST API Client
*/
export class SelfKintoneClient {
private client: KintoneRestAPIClient;
private domain: string;
constructor(domainConfig: DomainWithPassword) {
this.domain = domainConfig.domain;
const auth =
domainConfig.authType === "api_token"
? { apiToken: domainConfig.apiToken || "" }
: {
username: domainConfig.username,
password: domainConfig.password,
};
this.client = new KintoneRestAPIClient({
baseUrl: `https://${domainConfig.domain}`,
auth,
});
}
private convertError(error: unknown): KintoneError {
if (error && typeof error === "object" && "code" in error) {
const apiError = error as KintoneRestAPIError;
return new KintoneError(
apiError.message,
{ code: apiError.code, message: apiError.message, id: apiError.id },
apiError.status,
);
}
if (error instanceof Error) {
return new KintoneError(error.message);
}
return new KintoneError("Unknown error occurred");
}
private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
throw this.convertError(error);
}
}
private mapApp(app: Awaited<AppResponse>): KintoneApp {
return {
appId: app.appId,
name: app.name,
code: app.code,
spaceId: app.spaceId || undefined,
createdAt: app.createdAt,
creator: app.creator,
modifiedAt: app.modifiedAt,
modifier: app.modifier,
};
}
private mapResource(
resource:
| Awaited<AppCustomizeResponse>["desktop"]["js"][number]
| Awaited<AppCustomizeResponse>["desktop"]["css"][number],
): JSFileConfig | CSSFileConfig {
if (resource.type === "FILE") {
return {
type: "FILE",
file: {
fileKey: resource.file.fileKey,
name: resource.file.name,
size: parseInt(resource.file.size, 10),
},
};
}
return {
type: "URL",
url: resource.url,
};
}
private buildCustomizeSection(
js?: JSFileConfig[],
css?: CSSFileConfig[],
): Parameters<KintoneClient["app"]["updateAppCustomize"]>[0]["desktop"] {
if (!js && !css) return undefined;
const mapItem = (item: JSFileConfig | CSSFileConfig) => {
if (item.type === "FILE" && item.file) {
return { type: "FILE" as const, file: { fileKey: item.file.fileKey } };
}
return { type: "URL" as const, url: item.url || "" };
};
return {
js: js?.map(mapItem),
css: css?.map(mapItem),
};
}
// ==================== App APIs ====================
/**
* Get all apps with pagination support
* Fetches all apps by making multiple requests if needed
*/
async getApps(options?: {
limit?: number;
offset?: number;
}): Promise<KintoneApp[]> {
return this.withErrorHandling(async () => {
// If pagination options provided, use them directly
if (options?.limit !== undefined || options?.offset !== undefined) {
const params: { limit?: number; offset?: number } = {};
if (options.limit) params.limit = options.limit;
if (options.offset) params.offset = options.offset;
const response = await this.client.app.getApps(params);
return response.apps.map((app) => this.mapApp(app));
}
// Otherwise, fetch all apps (pagination handled internally)
const allApps: Awaited<AppsResponse>["apps"] = [];
const limit = 100; // Max allowed by Kintone API
let offset = 0;
let hasMore = true;
while (hasMore) {
const response = await this.client.app.getApps({ limit, offset });
allApps.push(...response.apps);
// If we got fewer than limit, we've reached the end
if (response.apps.length < limit) {
hasMore = false;
} else {
offset += limit;
}
}
return allApps.map((app) => this.mapApp(app));
});
}
async getAppDetail(appId: string): Promise<AppDetail> {
return this.withErrorHandling(async () => {
const [appInfo, customizeInfo] = await Promise.all([
this.client.app.getApp({ id: appId }),
this.client.app.getAppCustomize({ app: appId }),
]);
const customization: AppCustomizationConfig = {
javascript: {
pc: customizeInfo.desktop.js?.map((js) => this.mapResource(js)) || [],
mobile:
customizeInfo.mobile.js?.map((js) => this.mapResource(js)) || [],
},
stylesheet: {
pc:
customizeInfo.desktop.css?.map((css) => this.mapResource(css)) ||
[],
mobile:
customizeInfo.mobile.css?.map((css) => this.mapResource(css)) || [],
},
plugins: [],
};
return {
appId: appInfo.appId,
name: appInfo.name,
code: appInfo.code,
description: appInfo.description,
spaceId: appInfo.spaceId || undefined,
createdAt: appInfo.createdAt,
creator: appInfo.creator,
modifiedAt: appInfo.modifiedAt,
modifier: appInfo.modifier,
customization,
};
});
}
// ==================== File APIs ====================
async getFileContent(fileKey: string): Promise<FileContent> {
return this.withErrorHandling(async () => {
const data = await this.client.file.downloadFile({ fileKey });
const buffer = Buffer.from(data);
const content = buffer.toString("base64");
return {
fileKey,
name: fileKey,
size: buffer.byteLength,
mimeType: "application/octet-stream",
content,
};
});
}
async uploadFile(
content: string | Buffer,
fileName: string,
_mimeType?: string,
): Promise<{ fileKey: string }> {
return this.withErrorHandling(async () => {
const response = await this.client.file.uploadFile({
file: { name: fileName, data: content },
});
return { fileKey: response.fileKey };
});
}
// ==================== Deploy APIs ====================
async updateAppCustomize(
appId: string,
config: AppCustomizationConfig,
): Promise<void> {
return this.withErrorHandling(async () => {
await this.client.app.updateAppCustomize({
app: appId,
desktop: this.buildCustomizeSection(
config.javascript?.pc,
config.stylesheet?.pc,
),
mobile: this.buildCustomizeSection(
config.javascript?.mobile,
config.stylesheet?.mobile,
),
scope: "ALL",
});
});
}
async deployApp(appId: string): Promise<void> {
return this.withErrorHandling(async () => {
await this.client.app.deployApp({ apps: [{ app: appId }] });
});
}
async getDeployStatus(
appId: string,
): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
return this.withErrorHandling(async () => {
const response = await this.client.app.getDeployStatus({ apps: [appId] });
return response.apps[0]?.status || "FAIL";
});
}
// ==================== Utility Methods ====================
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
// Use limit=1 to minimize data transfer for faster connection testing
await this.client.app.getApps({ limit: 1 });
return { success: true };
} catch (error) {
return {
success: false,
error:
error instanceof KintoneError ? error.message : "Connection failed",
};
}
}
getDomain(): string {
return this.domain;
}
}
export function createKintoneClient(
domain: DomainWithPassword,
): SelfKintoneClient {
return new SelfKintoneClient(domain);
}