use rest-api-client

This commit is contained in:
2026-03-12 12:27:16 +08:00
parent d66dae74e8
commit 1e9a01b6c1
4 changed files with 430 additions and 619 deletions

View File

@@ -1,9 +1,5 @@
/**
* Kintone REST API Client
* Handles authentication and API calls to Kintone
* Based on REQUIREMENTS.md:331-345, 502-522
*/
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import type { KintoneRestAPIError } from "@kintone/rest-api-client";
import type { DomainWithPassword } from "@renderer/types/domain";
import type {
KintoneSpace,
@@ -12,10 +8,10 @@ import type {
FileContent,
AppCustomizationConfig,
KintoneApiError,
JSFileConfig,
CSSFileConfig,
} from "@renderer/types/kintone";
const REQUEST_TIMEOUT = 30000; // 30 seconds
/**
* Custom error class for Kintone API errors
*/
@@ -41,446 +37,256 @@ export class KintoneError extends Error {
* Kintone REST API Client
*/
export class KintoneClient {
private baseUrl: string;
private headers: Headers;
private client: KintoneRestAPIClient;
private domain: string;
constructor(domainConfig: DomainWithPassword) {
this.domain = domainConfig.domain;
this.baseUrl = `https://${domainConfig.domain}/k/v1/`;
this.headers = new Headers({
"Content-Type": "application/json",
const auth =
domainConfig.authType === "api_token"
? { apiToken: domainConfig.apiToken || "" }
: {
username: domainConfig.username,
password: domainConfig.password,
};
this.client = new KintoneRestAPIClient({
baseUrl: `https://${domainConfig.domain}`,
auth,
});
if (domainConfig.authType === "api_token") {
// API Token authentication
this.headers.set("X-Cybozu-API-Token", domainConfig.apiToken || "");
} else {
// Password authentication (Basic Auth)
const credentials = Buffer.from(
`${domainConfig.username}:${domainConfig.password}`,
).toString("base64");
this.headers.set("X-Cybozu-Authorization", credentials);
}
}
/**
* Make an API request with timeout
*/
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
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 {
const url = this.baseUrl + endpoint;
const response = await fetch(url, {
...options,
headers: this.headers,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok) {
const apiError = data as KintoneApiError;
throw new KintoneError(
apiError.message || `API request failed: ${response.status}`,
apiError,
response.status,
);
}
return data as T;
return await operation();
} catch (error) {
if (error instanceof KintoneError) {
throw error;
}
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new KintoneError("Request timeout");
}
throw new KintoneError(`Network error: ${error.message}`);
}
throw new KintoneError("Unknown error occurred");
} finally {
clearTimeout(timeoutId);
throw this.convertError(error);
}
}
// ==================== Space APIs ====================
/**
* Get list of spaces
* GET /k/v1/space.json
*/
async getSpaces(): Promise<KintoneSpace[]> {
interface SpacesResponse {
spaces: Array<{
id: string;
name: string;
code: string;
createdAt?: string;
creator?: { code: string; name: string };
}>;
}
const response = await this.request<SpacesResponse>("space.json");
return response.spaces.map((space) => ({
id: space.id,
private mapSpace(space: {
id: string | number;
name: string;
code: string;
createdAt?: string;
creator?: { code: string; name: string };
}): KintoneSpace {
return {
id: String(space.id),
name: space.name,
code: space.code,
createdAt: space.createdAt,
creator: space.creator,
}));
creator: space.creator
? { code: space.creator.code, name: space.creator.name }
: undefined,
};
}
// ==================== App APIs ====================
/**
* Get list of apps, optionally filtered by space
* GET /k/v1/apps.json?space={spaceId}
*/
async getApps(spaceId?: string): Promise<KintoneApp[]> {
interface AppsResponse {
apps: Array<{
appId: string;
name: string;
code?: string;
spaceId?: string;
createdAt: string;
creator?: { code: string; name: string };
modifiedAt?: string;
modifier?: { code: string; name: string };
}>;
}
let endpoint = "apps.json";
if (spaceId) {
endpoint += `?space=${spaceId}`;
}
const response = await this.request<AppsResponse>(endpoint);
return response.apps.map((app) => ({
appId: app.appId,
private mapApp(app: {
appId: string | number;
name: string;
code?: string;
spaceId?: string | number;
createdAt: string;
creator?: { code: string; name: string };
modifiedAt?: string;
modifier?: { code: string; name: string };
}): KintoneApp {
return {
appId: String(app.appId),
name: app.name,
code: app.code,
spaceId: app.spaceId,
spaceId: app.spaceId ? String(app.spaceId) : undefined,
createdAt: app.createdAt,
creator: app.creator,
modifiedAt: app.modifiedAt,
modifier: app.modifier,
}));
};
}
/**
* Get app details including customization config
* GET /k/v1/app.json?app={appId}
*/
async getAppDetail(appId: string): Promise<AppDetail> {
interface AppResponse {
appId: string;
name: string;
code?: string;
description?: string;
spaceId?: string;
createdAt: string;
creator: { code: string; name: string };
modifiedAt: string;
modifier: { code: string; name: string };
}
interface AppCustomizeResponse {
appId: string;
scope: string;
desktop: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
mobile: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
}
// Get basic app info
const appInfo = await this.request<AppResponse>(`app.json?app=${appId}`);
// Get customization config
const customizeInfo = await this.request<AppCustomizeResponse>(
`app/customize.json?app=${appId}`,
);
// Transform customization config
const customization: AppCustomizationConfig = {
javascript: {
pc:
customizeInfo.desktop?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
mobile:
customizeInfo.mobile?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
},
stylesheet: {
pc:
customizeInfo.desktop?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
mobile:
customizeInfo.mobile?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
},
plugins: [],
private mapResource(resource: {
type: string;
file?: { fileKey: string; name: string };
url?: string;
}): JSFileConfig | CSSFileConfig {
return {
type: resource.type as "FILE" | "URL",
file: resource.file
? { fileKey: resource.file.fileKey, name: resource.file.name, size: 0 }
: undefined,
url: resource.url,
};
}
private buildCustomizeSection(
js?: JSFileConfig[],
css?: CSSFileConfig[],
):
| { js?: CustomizeResourceItem[]; css?: CustomizeResourceItem[] }
| undefined {
if (!js && !css) return undefined;
return {
appId: appInfo.appId,
name: appInfo.name,
code: appInfo.code,
description: appInfo.description,
spaceId: appInfo.spaceId,
createdAt: appInfo.createdAt,
creator: appInfo.creator,
modifiedAt: appInfo.modifiedAt,
modifier: appInfo.modifier,
customization,
js: js?.map((item) => ({
type: item.type,
file: item.file ? { fileKey: item.file.fileKey } : undefined,
url: item.url,
})),
css: css?.map((item) => ({
type: item.type,
file: item.file ? { fileKey: item.file.fileKey } : undefined,
url: item.url,
})),
};
}
// ==================== Space APIs ====================
async getSpaces(): Promise<KintoneSpace[]> {
return this.withErrorHandling(async () => {
const response = await this.client.space.getSpaces();
return response.spaces.map((space) => this.mapSpace(space));
});
}
// ==================== App APIs ====================
async getApps(spaceId?: string): Promise<KintoneApp[]> {
return this.withErrorHandling(async () => {
const params = spaceId ? { spaceId } : {};
const response = await this.client.app.getApps(params);
return response.apps.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: String(appInfo.appId),
name: appInfo.name,
code: appInfo.code,
description: appInfo.description,
spaceId: appInfo.spaceId ? String(appInfo.spaceId) : undefined,
createdAt: appInfo.createdAt,
creator: appInfo.creator,
modifiedAt: appInfo.modifiedAt,
modifier: appInfo.modifier,
customization,
};
});
}
// ==================== File APIs ====================
/**
* Get file content from Kintone
* GET /k/v1/file.json?fileKey={fileKey}
*/
async getFileContent(fileKey: string): Promise<FileContent> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = `${this.baseUrl}file.json?fileKey=${fileKey}`;
const response = await fetch(url, {
headers: this.headers,
signal: controller.signal,
});
if (!response.ok) {
const error = await response.json();
throw new KintoneError(
error.message || "Failed to get file",
error,
response.status,
);
}
const contentType =
response.headers.get("content-type") || "application/octet-stream";
const arrayBuffer = await response.arrayBuffer();
const content = Buffer.from(arrayBuffer).toString("base64");
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, // Kintone doesn't return filename in file API
size: arrayBuffer.byteLength,
mimeType: contentType,
name: fileKey,
size: buffer.byteLength,
mimeType: "application/octet-stream",
content,
};
} finally {
clearTimeout(timeoutId);
}
});
}
/**
* Upload a file to Kintone
* POST /k/v1/file.json (multipart/form-data)
*/
async uploadFile(
content: string | Buffer,
fileName: string,
mimeType: string = "application/javascript",
_mimeType?: string,
): Promise<{ fileKey: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = `${this.baseUrl}file.json`;
// Create form data
const formData = new FormData();
const blob = new Blob(
[typeof content === "string" ? content : Buffer.from(content)],
{ type: mimeType },
);
formData.append("file", blob, fileName);
// Remove Content-Type header to let browser set it with boundary
const uploadHeaders = new Headers(this.headers);
uploadHeaders.delete("Content-Type");
const response = await fetch(url, {
method: "POST",
headers: uploadHeaders,
body: formData,
signal: controller.signal,
return this.withErrorHandling(async () => {
const response = await this.client.file.uploadFile({
file: { name: fileName, data: content },
});
if (!response.ok) {
const error = await response.json();
throw new KintoneError(
error.message || "Failed to upload file",
error,
response.status,
);
}
const data = await response.json();
return { fileKey: data.fileKey };
} finally {
clearTimeout(timeoutId);
}
return { fileKey: response.fileKey };
});
}
// ==================== Deploy APIs ====================
/**
* Update app customization config
* PUT /k/v1/app/customize.json
*/
async updateAppCustomize(
appId: string,
config: AppCustomizationConfig,
): Promise<void> {
interface CustomizeUpdateRequest {
app: string;
desktop?: {
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
};
mobile?: {
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
};
}
const requestBody: CustomizeUpdateRequest = {
app: appId,
desktop: {
js: config.javascript?.pc
?.map((js) => ({
type: js.type,
file: js.file ? { fileKey: js.file.fileKey } : undefined,
url: js.url,
}))
.filter(Boolean),
css: config.stylesheet?.pc
?.map((css) => ({
type: css.type,
file: css.file ? { fileKey: css.file.fileKey } : undefined,
url: css.url,
}))
.filter(Boolean),
},
mobile: {
js: config.javascript?.mobile
?.map((js) => ({
type: js.type,
file: js.file ? { fileKey: js.file.fileKey } : undefined,
url: js.url,
}))
.filter(Boolean),
css: config.stylesheet?.mobile
?.map((css) => ({
type: css.type,
file: css.file ? { fileKey: css.file.fileKey } : undefined,
url: css.url,
}))
.filter(Boolean),
},
};
await this.request("app/customize.json", {
method: "PUT",
body: JSON.stringify(requestBody),
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",
});
});
}
/**
* Deploy app changes
* POST /k/v1/preview/app/deploy.json
*/
async deployApp(appId: string): Promise<void> {
await this.request("preview/app/deploy.json", {
method: "POST",
body: JSON.stringify({ apps: [{ app: appId }] }),
return this.withErrorHandling(async () => {
await this.client.app.deployApp({ apps: [{ app: appId }] });
});
}
/**
* Get deploy status
* GET /k/v1/preview/app/deploy.json?app={appId}
*/
async getDeployStatus(
appId: string,
): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
interface DeployStatusResponse {
apps: Array<{
app: string;
status: "PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL";
}>;
}
const response = await this.request<DeployStatusResponse>(
`preview/app/deploy.json?app=${appId}`,
);
const appStatus = response.apps.find((a) => a.app === appId);
return appStatus?.status || "FAIL";
return this.withErrorHandling(async () => {
const response = await this.client.app.getDeployStatus({ app: appId });
return response.apps[0]?.status || "FAIL";
});
}
// ==================== Utility Methods ====================
/**
* Test connection to Kintone
*/
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
await this.getApps();
@@ -494,15 +300,17 @@ export class KintoneClient {
}
}
/**
* Get the domain name
*/
getDomain(): string {
return this.domain;
}
}
// Export factory function for convenience
type CustomizeResourceItem = {
type: string;
file?: { fileKey: string };
url?: string;
};
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
return new KintoneClient(domain);
}