318 lines
8.7 KiB
TypeScript
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);
|
|
}
|