use rest-api-client
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user