yao/sui/libsui/openapi.ts
Max 097fb73416 Refactor SUI Command Arguments and Enhance Default Template Handling
- Updated the `build` and `watch` commands to accept a single argument for `<sui>` and made the `<template>` argument optional, improving usability.
- Introduced a default template assignment for the `agent` SUI, ensuring a more intuitive setup for users.
- Enhanced error messages for command usage to provide clearer guidance on expected input format.
- Added new OpenAPI file to the bindata, improving the framework's capabilities for API integration.
2026-01-01 10:28:45 +08:00

807 lines
22 KiB
TypeScript

/**
* SUI OpenAPI Client
*
* A lightweight HTTP client for Yao OpenAPI endpoints.
* Adapted from CUI OpenAPI for use in SUI (browser-only, no build tools).
*
* Features:
* - RESTful API methods (GET, POST, PUT, DELETE, Upload)
* - Secure cookie authentication
* - CSRF protection
* - File upload with progress tracking
* - Cross-origin support
*
* Usage:
* const api = new OpenAPI({ baseURL: '/api' })
* const response = await api.Get('/users')
* if (api.IsError(response)) {
* console.error(response.error)
* } else {
* console.log(response.data)
* }
*/
// ============================================================================
// Types
// ============================================================================
interface OpenAPIConfig {
baseURL: string;
timeout?: number;
defaultHeaders?: Record<string, string>;
}
interface ErrorResponse {
error: string;
error_description?: string;
error_uri?: string;
[key: string]: any;
}
interface ApiResponse<T = any> {
data?: T;
error?: ErrorResponse;
status: number;
headers: Headers;
}
interface FileUploadOptions {
uploaderID?: string;
originalFilename?: string;
groups?: string[];
gzip?: boolean;
compressImage?: boolean;
compressSize?: number;
path?: string;
chunked?: boolean;
chunkSize?: number;
public?: boolean;
share?: "private" | "team";
}
interface FileListOptions {
uploaderID?: string;
page?: number;
pageSize?: number;
status?: string;
contentType?: string;
name?: string;
orderBy?: string;
select?: string[];
}
interface FileInfo {
file_id: string;
user_path: string;
path: string;
bytes: number;
created_at: number;
filename: string;
content_type: string;
status: string;
url?: string;
metadata?: Record<string, any>;
uploader?: string;
groups?: string[];
public?: boolean;
share?: "private" | "team";
}
interface FileListResponse {
data: FileInfo[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface FileExistsResponse {
exists: boolean;
fileId: string;
}
interface FileDeleteResponse {
message: string;
fileId: string;
}
type UploadProgressCallback = (progress: {
loaded: number;
total: number;
percentage: number;
}) => void;
// ============================================================================
// OpenAPI Client
// ============================================================================
class OpenAPI {
private config: OpenAPIConfig;
constructor(config: OpenAPIConfig) {
this.config = config;
}
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
const apiResponse: ApiResponse<T> = {
status: response.status,
headers: response.headers,
};
try {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const jsonData = await response.json();
if (!response.ok && jsonData.error) {
apiResponse.error = jsonData as ErrorResponse;
} else if (response.ok) {
apiResponse.data = jsonData as T;
} else {
apiResponse.error = {
error: "http_error",
error_description: `HTTP ${response.status}: ${response.statusText}`,
};
}
} else if (response.ok) {
const textData = await response.text();
apiResponse.data = textData as unknown as T;
} else {
const errorText = await response.text();
apiResponse.error = {
error: "http_error",
error_description:
errorText || `HTTP ${response.status}: ${response.statusText}`,
};
}
} catch (parseError) {
apiResponse.error = {
error: "parse_error",
error_description: `Failed to parse response: ${
parseError instanceof Error ? parseError.message : "Unknown error"
}`,
};
}
return apiResponse;
}
async Get<T = any>(
path: string,
query: Record<string, string> = {},
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const queryString = new URLSearchParams(query).toString();
let url = `${this.config.baseURL}${path}`;
if (queryString) {
url += path.includes("?") ? `&${queryString}` : `?${queryString}`;
}
const response = await fetch(url, {
method: "GET",
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Post<T = any>(
path: string,
payload: any,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "POST",
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Put<T = any>(
path: string,
payload: any,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "PUT",
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Delete<T = any>(
path: string,
headersInit: Record<string, string> = {},
payload?: any
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const requestOptions: RequestInit = {
method: "DELETE",
headers,
credentials: "include",
};
if (payload !== undefined) {
requestOptions.body = JSON.stringify(payload);
}
const response = await fetch(
`${this.config.baseURL}${path}`,
requestOptions
);
return this.handleResponse<T>(response);
}
async Upload<T = any>(
path: string,
formData: FormData,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { ...headersInit };
this.addCSRFToken(headers);
// Don't set Content-Type for FormData - browser sets it with boundary
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "POST",
body: formData,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
// ============================================================================
// Helper Methods
// ============================================================================
IsError<T>(
response: ApiResponse<T>
): response is ApiResponse<T> & { error: ErrorResponse } {
return response.error !== undefined;
}
GetData<T>(response: ApiResponse<T>): T | null {
return response.data || null;
}
SetCSRFToken(token: string): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem("csrf_token", token);
}
}
ClearTokens(): void {
if (typeof localStorage !== "undefined") {
localStorage.removeItem("csrf_token");
localStorage.removeItem("xsrf_token");
}
}
IsCrossOrigin(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
const apiUrl = new URL(this.config.baseURL, window.location.origin);
return apiUrl.origin !== window.location.origin;
} catch {
return true;
}
}
getBaseURL(): string {
return this.config.baseURL;
}
// ============================================================================
// Private Methods
// ============================================================================
private addCSRFToken(headers: Record<string, string>): void {
// Try cookies
const cookieToken =
this.getSecureCookie("__Host-csrf_token") ||
this.getSecureCookie("__Secure-csrf_token") ||
this.getSecureCookie("__Host-xsrf_token") ||
this.getSecureCookie("__Secure-xsrf_token");
if (cookieToken) {
headers["X-CSRF-Token"] = cookieToken;
return;
}
// Try localStorage
if (typeof localStorage !== "undefined") {
const storedToken =
localStorage.getItem("csrf_token") ||
localStorage.getItem("xsrf_token");
if (storedToken) {
headers["X-CSRF-Token"] = storedToken;
return;
}
}
// Try meta tag
if (typeof document !== "undefined") {
const metaToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") ||
document
.querySelector('meta[name="xsrf-token"]')
?.getAttribute("content");
if (metaToken) {
headers["X-CSRF-Token"] = metaToken;
}
}
}
private getSecureCookie(name: string): string | null {
if (typeof document === "undefined") {
return null;
}
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const cookieValue = parts.pop()?.split(";").shift();
return cookieValue ? decodeURIComponent(cookieValue) : null;
}
return null;
}
}
// ============================================================================
// File API
// ============================================================================
class FileAPI {
private api: OpenAPI;
private defaultUploader: string;
constructor(api: OpenAPI, defaultUploader?: string) {
this.api = api;
this.defaultUploader = defaultUploader || "__yao.attachment";
}
async Upload(
file: File,
options: FileUploadOptions = {},
onProgress?: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
const uploaderID = options.uploaderID || this.defaultUploader;
const shouldUseChunked =
options.chunked || file.size > (options.chunkSize || 2 * 1024 * 1024);
if (shouldUseChunked) {
return this.uploadChunked(uploaderID, file, options, onProgress);
}
const formData = new FormData();
formData.append("file", file);
if (options.originalFilename || file.name) {
formData.append(
"original_filename",
options.originalFilename || file.name
);
}
if (options.path) formData.append("path", options.path);
if (options.groups?.length)
formData.append("groups", options.groups.join(","));
if (options.gzip) formData.append("gzip", "true");
if (options.compressImage) formData.append("compress_image", "true");
if (options.compressSize)
formData.append("compress_size", options.compressSize.toString());
if (options.public !== undefined)
formData.append("public", options.public ? "true" : "false");
if (options.share) formData.append("share", options.share);
if (onProgress) {
return this.uploadWithProgress(uploaderID, formData, onProgress);
}
return this.api.Upload<FileInfo>(`/file/${uploaderID}`, formData);
}
async UploadMultiple(
files: File[],
options: FileUploadOptions = {},
onProgress?: (
fileIndex: number,
progress: { loaded: number; total: number; percentage: number }
) => void
): Promise<ApiResponse<FileInfo>[]> {
const uploadPromises = files.map((file, index) => {
const progressCallback = onProgress
? (progress: { loaded: number; total: number; percentage: number }) =>
onProgress(index, progress)
: undefined;
return this.Upload(file, options, progressCallback);
});
return Promise.all(uploadPromises);
}
async List(
options: FileListOptions = {}
): Promise<ApiResponse<FileListResponse>> {
const uploaderID = options.uploaderID || this.defaultUploader;
const params: Record<string, string> = {};
if (options.page) params.page = options.page.toString();
if (options.pageSize) params.page_size = options.pageSize.toString();
if (options.status) params.status = options.status;
if (options.contentType) params.content_type = options.contentType;
if (options.name) params.name = options.name;
if (options.orderBy) params.order_by = options.orderBy;
if (options.select?.length) params.select = options.select.join(",");
return this.api.Get<FileListResponse>(`/file/${uploaderID}`, params);
}
async Retrieve(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileInfo>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Get<FileInfo>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
);
}
async Delete(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileDeleteResponse>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Delete<FileDeleteResponse>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
);
}
async Download(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<Blob>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
const url = `${this.api.getBaseURL()}/file/${actualUploaderID}/${encodeURIComponent(
fileID
)}/content`;
const response = await fetch(url, {
method: "GET",
credentials: "include",
});
const blob = await response.blob();
const apiResponse: ApiResponse<Blob> = {
data: blob,
status: response.status,
headers: response.headers,
};
if (!response.ok) {
apiResponse.error = {
error: "download_failed",
error_description: `Download failed with status ${response.status}`,
};
}
return apiResponse;
}
async Exists(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileExistsResponse>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Get<FileExistsResponse>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}/exists`
);
}
// Static utility methods
static FormatSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
static GetExtension(filename: string): string {
return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
}
static IsImage(contentType: string): boolean {
return contentType.startsWith("image/");
}
static IsDocument(contentType: string): boolean {
const documentTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
];
return documentTypes.includes(contentType);
}
// Private methods
private async uploadChunked(
uploaderID: string,
file: File,
options: FileUploadOptions = {},
onProgress?: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
const chunkSize = options.chunkSize || 2 * 1024 * 1024;
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
const fileUID = this.generateUID();
let lastResponse: ApiResponse<FileInfo> | null = null;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize - 1, totalSize - 1);
const chunkBlob = file.slice(start, end + 1);
const formData = new FormData();
formData.append("file", chunkBlob);
if (chunkIndex === 0) {
if (options.originalFilename || file.name) {
formData.append(
"original_filename",
options.originalFilename || file.name
);
}
if (options.path) formData.append("path", options.path);
if (options.groups?.length)
formData.append("groups", options.groups.join(","));
if (options.gzip) formData.append("gzip", "true");
if (options.compressImage) formData.append("compress_image", "true");
if (options.compressSize)
formData.append("compress_size", options.compressSize.toString());
if (options.public !== undefined)
formData.append("public", options.public ? "true" : "false");
if (options.share) formData.append("share", options.share);
}
const chunkResponse = await this.uploadChunk(
uploaderID,
formData,
start,
end,
totalSize,
fileUID
);
if (this.api.IsError(chunkResponse)) {
return chunkResponse;
}
lastResponse = chunkResponse;
if (onProgress) {
const loaded = end + 1;
const percentage = Math.round((loaded / totalSize) * 100);
onProgress({ loaded, total: totalSize, percentage });
}
}
return lastResponse!;
}
private uploadChunk(
uploaderID: string,
formData: FormData,
start: number,
end: number,
total: number,
uid: string
): Promise<ApiResponse<FileInfo>> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener("load", () => {
try {
const response = JSON.parse(xhr.responseText);
const apiResponse: ApiResponse<FileInfo> = {
data: response.data || response,
status: xhr.status,
headers: new Headers(),
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(apiResponse);
} else {
apiResponse.error = response.error || {
error: "chunk_upload_failed",
error_description: `Chunk upload failed with status ${xhr.status}`,
};
resolve(apiResponse);
}
} catch {
resolve({
status: xhr.status,
headers: new Headers(),
error: {
error: "parse_error",
error_description: "Failed to parse chunk response",
},
});
}
});
xhr.addEventListener("error", () => {
resolve({
status: xhr.status || 0,
headers: new Headers(),
error: {
error: "network_error",
error_description: "Network error during chunk upload",
},
});
});
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
xhr.setRequestHeader("Content-Sync", "true");
xhr.setRequestHeader("Content-Uid", uid);
xhr.setRequestHeader("Content-Range", `bytes ${start}-${end}/${total}`);
const csrfToken = this.getCSRFToken();
if (csrfToken) {
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
xhr.withCredentials = true;
xhr.send(formData);
});
}
private uploadWithProgress(
uploaderID: string,
formData: FormData,
onProgress: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded / event.total) * 100);
onProgress({ loaded: event.loaded, total: event.total, percentage });
}
});
xhr.addEventListener("load", () => {
try {
const response = JSON.parse(xhr.responseText);
const apiResponse: ApiResponse<FileInfo> = {
data: response.data || response,
status: xhr.status,
headers: new Headers(),
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(apiResponse);
} else {
apiResponse.error = response.error || {
error: "upload_failed",
error_description: `Upload failed with status ${xhr.status}`,
};
resolve(apiResponse);
}
} catch {
resolve({
status: xhr.status,
headers: new Headers(),
error: {
error: "parse_error",
error_description: "Failed to parse response",
},
});
}
});
xhr.addEventListener("error", () => {
resolve({
status: xhr.status || 0,
headers: new Headers(),
error: {
error: "network_error",
error_description: "Network error during upload",
},
});
});
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
const csrfToken = this.getCSRFToken();
if (csrfToken) {
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
xhr.withCredentials = true;
xhr.send(formData);
});
}
private getCSRFToken(): string | null {
if (typeof document !== "undefined") {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (
name === "__Host-csrf_token" ||
name === "__Secure-csrf_token" ||
name === "__Host-xsrf_token" ||
name === "__Secure-xsrf_token"
) {
return decodeURIComponent(value);
}
}
}
if (typeof localStorage !== "undefined") {
return (
localStorage.getItem("csrf_token") || localStorage.getItem("xsrf_token")
);
}
return null;
}
private generateUID(): string {
// Simple unique ID generator (no external dependencies)
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
// ============================================================================
// Global Registration for SUI
// ============================================================================
// Make available globally for SUI pages (no export, direct global assignment)
(window as any).OpenAPI = OpenAPI;
(window as any).FileAPI = FileAPI;