🌟
ReactでWebFOCUSのRESTful APIを使ったアプリケーション(実践編・その1)
ReactでWebFOCUSのRESTful APIを使ったアプリケーション(実践編・その1)
はじめに
WebFOCUSのRESTful APIを使ってWebアプリケーションを何度かに分けて作成します。
Reactは初心者なので見守るか優しくアドバイスください。
開発する機能
様々なIBIRS_actionを使った実験的なモデルです。
- フォーム認証によるログイン
- ワークスペースの資産の表示と編集と削除
- アプリケーションディレクトリの資産の表示と編集と削除
- 埋め込みアプリケーションを配置したダッシュボード
- デザイナやエディタなどのWebFOCUSクライアントツールと連携
- セキュリティセンターの簡易的なもの
が、どうにか形になれば良いと思っています。
開発環境
viteで、React + TypeScriptの環境を作りました。
動作イメージ
シンプルなログイン画面
グリッドレイアウトのメイン画面
- ヘッダーにユーザ情報を雑に表示
- 左にナビゲーション
- 右がメインの表示エリア
- フッターはただの文字
ワークスペースをクリックしたらアイテムをカード型で表示
- フォルダのクリックで掘り下げていくスタイル
- WebFOCUSに登録されている画像とdescriptionを表示しても良い
WebFOCUSサービスモジュール
ログインとワークスペースの表示が出来るメソッドを準備した状態です。
- WebFocusService内では、WebFOCUSのレスポンスXMLをquerySelectorでXMLのまま扱う
- 戻り値は、汎用的に使える形のJSONで返す(使いそうなAttributeは全部返す)
- POSTリクエストの時だけCSRFトークンを付ける
- CSRFトークンのゲッターは要らなくなる予定
- 文字列のほとんどは、定数化する予定
- 他の
Content-Type
も必要になるかもしれない - returnCodeは必ず返る?
WebFocusService.ts
/**
* ログイン結果の型定義
*/
interface LoginResult {
success: boolean;
message: string;
userDisplayName?: string | null;
userFullPath?: string | null;
csrfTokenName?: string | null;
csrfTokenValue?: string | null;
}
/**
* フォルダアイテムのインターフェース
*/
export interface FolderItem {
name: string;
description: string | null;
fullPath: string;
type: string;
typeDescription: string;
thumbPath: string;
createdBy: string;
lastModified: string;
container: boolean;
}
/**
* リソース取得結果の型定義
*/
export interface ResourceResult {
success: boolean;
message: string;
items: FolderItem[];
}
/**
* WebFOCUS IBFS サービスとの通信を管理するクラス
*/
export class WebFocusService {
// 共通定数
private static readonly SUCCESS_CODE = '10000';
private static readonly IBIRS_SERVICE = 'ibfs';
private readonly baseUrl: string;
private userName: string | null = null;
private userDisplayName: string | null = null;
private userFullPath: string | null = null;
private csrfTokenName: string | null = null;
private csrfTokenValue: string | null = null;
/**
* @param {Object} config - 設定オブジェクト
* @param {string} [config.baseUrl='/ibi_apps/rs'] - WebFOCUS REST APIのベースURL
*/
constructor(config: { baseUrl?: string } = {}) {
this.baseUrl = config.baseUrl ?? '/ibi_apps/rs';
}
/**
* WebFOCUSにログインする
*
* @param {string} username - ユーザ名
* @param {string} password - パスワード
* @returns {Promise<LoginResult>} ログイン結果
*/
public async login(username: string, password: string): Promise<LoginResult> {
try {
const body = new URLSearchParams({
IBIRS_action: 'signOn',
IBIRS_service: WebFocusService.IBIRS_SERVICE,
IBIRS_userName: username,
IBIRS_password: password,
});
const { xmlDoc, returnCode, returnDesc } = await this.fetchAndParseXml('signOn', 'POST', {}, body);
if (this.isSuccessResponse(returnCode)) {
// ユーザー情報とCSRFトークンを抽出
this.extractUserInfoAndTokens(xmlDoc);
return {
success: true,
message: returnDesc || 'Login successful',
userDisplayName: this.userDisplayName,
userFullPath: this.userFullPath,
csrfTokenName: this.csrfTokenName,
csrfTokenValue: this.csrfTokenValue
};
} else {
// ログイン失敗
return {
success: false,
message: returnDesc || 'Authentication failed'
};
}
} catch (error) {
console.error('Login error details:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* 指定されたパスのリソースを取得する
*
* @param {string} path - リソースパス (例: "IBFS:/WFC/Repository")
* @returns {Promise<ResourceResult>} リソース取得結果
*/
public async getResourceItems(path: string): Promise<ResourceResult> {
try {
const params = {
IBIRS_path: path,
IBIRS_args: '__null'
};
const { xmlDoc, returnCode, returnDesc } = await this.fetchAndParseXml('get', 'GET', params);
if (this.isSuccessResponse(returnCode)) {
// フォルダアイテムを抽出
const items = this.extractFolderItems(xmlDoc);
return {
success: true,
message: returnDesc || 'Success',
items
};
} else {
return {
success: false,
message: returnDesc || 'Failed to get resource',
items: []
};
}
} catch (error) {
console.error('Resource fetch error:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
items: []
};
}
}
/**
* 共通のfetchとXML解析処理
*
* @param action - 実行するアクション
* @param method - HTTPメソッド
* @param params - リクエストパラメータ
* @param body - POSTリクエスト用のボディ
* @returns 解析されたXMLドキュメントとステータス
*/
private async fetchAndParseXml(
action: string,
method: 'GET' | 'POST' = 'GET',
params: Record<string, string> = {},
body?: URLSearchParams
): Promise<{ xmlDoc: Document, returnCode: string, returnDesc: string }> {
// リクエストパラメータの構築
const requestParams = new URLSearchParams({
IBIRS_action: action,
IBIRS_service: WebFocusService.IBIRS_SERVICE,
...params
});
// URLとリクエストオプションの構築
const url = method === 'GET' ? `${this.baseUrl}?${requestParams.toString()}` : this.baseUrl;
const requestOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
};
// POSTリクエストの場合はボディとCSRFトークンを追加
if (method === 'POST') {
requestOptions.body = body || requestParams;
// CSRFトークンがある場合はヘッダーに追加
if (this.csrfTokenName && this.csrfTokenValue) {
requestOptions.headers = {
...requestOptions.headers,
[this.csrfTokenName]: this.csrfTokenValue
};
console.log(`Adding CSRF token to headers: ${this.csrfTokenName}=${this.csrfTokenValue}`);
}
}
// リクエスト実行
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status} ${response.statusText}`);
}
// XMLレスポンスの解析
const xmlText = await response.text();
console.log(`XML Response for ${action}:`, xmlText); // デバッグ用ログ
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
// 戻りコードと説明の抽出
const ibfsrpc = xmlDoc.querySelector('ibfsrpc');
const returnCode = ibfsrpc?.getAttribute('returncode') || '';
const returnDesc = ibfsrpc?.getAttribute('returndesc') || '';
return { xmlDoc, returnCode, returnDesc };
}
/**
* レスポンスが成功かどうか確認
*/
private isSuccessResponse(returnCode: string): boolean {
return returnCode === WebFocusService.SUCCESS_CODE;
}
/**
* XMLからユーザー情報とCSRFトークンを抽出
*/
private extractUserInfoAndTokens(xmlDoc: Document): void {
// CSRFトークンの抽出
const properties = xmlDoc.querySelector('properties');
if (properties) {
const entries = properties.querySelectorAll('entry');
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const key = entry.getAttribute('key');
const value = entry.getAttribute('value');
if (key === 'IBI_CSRF_Token_Name') {
this.csrfTokenName = value;
} else if (key === 'IBI_CSRF_Token_Value') {
this.csrfTokenValue = value;
}
}
}
// ユーザー情報の抽出
const rootObject = xmlDoc.querySelector('rootObject');
if (rootObject) {
this.userDisplayName = rootObject.getAttribute('description') || null;
this.userFullPath = rootObject.getAttribute('fullPath') || null;
this.userName = rootObject.getAttribute('name') || null;
// 表示名が空の場合はname属性を使用
if (!this.userDisplayName || this.userDisplayName.trim() === '') {
this.userDisplayName = rootObject.getAttribute('name') || null;
}
}
console.log('Extracted data:', {
csrfTokenName: this.csrfTokenName,
csrfTokenValue: this.csrfTokenValue,
userDisplayName: this.userDisplayName,
userFullPath: this.userFullPath
});
}
/**
* XMLからフォルダアイテムを抽出
*/
private extractFolderItems(xmlDoc: Document): FolderItem[] {
const items: FolderItem[] = [];
const itemElements = xmlDoc.querySelectorAll('rootObject > children > item');
itemElements.forEach(item => {
// 属性の抽出
const name = item.getAttribute('name') || '';
const description = item.getAttribute('description');
const fullPath = item.getAttribute('fullPath') || '';
const type = item.getAttribute('type') || '';
const typeDescription = item.getAttribute('typeDescription') || '';
const thumbPath = item.getAttribute('thumbPath') || '';
const createdBy = item.getAttribute('createdBy') || '';
const lastModified = item.getAttribute('lastModified') || '';
const container = item.getAttribute('container') === 'true';
items.push({
name,
description,
fullPath,
type,
typeDescription,
thumbPath,
createdBy,
lastModified,
container
});
});
return items;
}
/**
* ユーザー名を取得
*/
get currentUserName(): string | null {
return this.userName;
}
/**
* ユーザーの表示名を取得
*/
get currentUserDisplayName(): string | null {
return this.userDisplayName;
}
/**
* ユーザーのフルパスを取得
*/
get currentUserFullPath(): string | null {
return this.userFullPath;
}
/**
* CSRFトークン名を取得
*/
get currentCsrfTokenName(): string | null {
return this.csrfTokenName;
}
/**
* CSRFトークン値を取得
*/
get currentCsrfTokenValue(): string | null {
return this.csrfTokenValue;
}
}
サービスモジュールの作り方
- 泥臭くても
/ibi_apps/rs?IBIRS_action=TEST
ページで動かしながらfetchAndParseXml
に渡すパラメータを考える - 泥臭くても
/ibi_apps/rs?IBIRS_action=TEST
ページで動かしながら返されるXMLの解析文を書く - 出来るだけ汎用的にXMLをJSONにして結果を返す
課題
- クロスオリジンな開発環境ではPOSTリクエストを拒否される(Proxyで騙せる?)
- サービスモジュールがまだまだ巨大化する予定(どの単位でモジュールを分ける?)
- Reactが下手くそ
- 今後もテストページで手探りしながら開発
- 飽きてきた
WebAppモジュール(初心者のReact)
サービスモジュール以外はWebFOCUSと関係ないので参考程度に
index.html
特筆すべき事項はありません。
index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS + WebFOCUS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
main.tsx
デフォルトのまま
main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
app.tsx
ログインで返される情報は、Appに持たせる!あとは、あれもこれもといつもの悪い癖です。
- isLoadingは1つで大丈夫?
- トークンはここに持たない方がいい気がする
- 結局全部のプロパティとハンドルがAppに集まりそう
- もうコンポーネントをclassにしたくなってきた
app.tsx
import { useState } from 'react';
import { Login } from './components/Login/Login';
import { WebFocusService, FolderItem } from './services/WebFocusService';
import MainLayout from './components/Layout/MainLayout';
import FolderCardGrid from './components/Card/FolderCardGrid';
import './App.css';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [userDisplayName, setUserDisplayName] = useState<string | null>(null);
const [userFullPath, setUserFullPath] = useState<string | null>(null);
const [csrfTokenName, setCsrfTokenName] = useState<string | null>(null);
const [csrfTokenValue, setCsrfTokenValue] = useState<string | null>(null);
// ワークスペース表示用の状態
const [folderItems, setFolderItems] = useState<FolderItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const webfocusService = new WebFocusService();
const handleLogin = async (username: string, password: string) => {
try {
const loginResult = await webfocusService.login(username, password);
if (loginResult.success) {
setUserName(username);
setIsAuthenticated(true);
setUserDisplayName(loginResult.userDisplayName || null);
setCsrfTokenName(loginResult.csrfTokenName || null);
setCsrfTokenValue(loginResult.csrfTokenValue || null);
setUserFullPath(loginResult.userFullPath || null);
console.log('保存した状態変数:', {
userName: username,
userDisplayName: loginResult.userDisplayName,
csrfTokenName: loginResult.csrfTokenName,
csrfTokenValue: loginResult.csrfTokenValue,
userFullPath: loginResult.userFullPath
});
return;
}
throw new Error(loginResult.message);
} catch (error) {
console.error('Login error:', error);
throw error;
}
};
const handleWorkspaceClick = async () => {
try {
setIsLoading(true);
setErrorMessage(null);
const result = await webfocusService.getResourceItems('IBFS:/WFC/Repository');
if (result.success) {
setFolderItems(result.items);
} else {
setErrorMessage(result.message);
setFolderItems([]);
}
} catch (error) {
console.error('Error loading workspace:', error);
setErrorMessage('ワークスペースの読み込み中にエラーが発生しました');
setFolderItems([]);
} finally {
setIsLoading(false);
}
};
const handleFolderItemClick = (item: FolderItem) => {
console.log('Folder item clicked:', item);
// ここに項目クリック時の処理を実装
};
// 右パネルに表示するコンテンツを決定
const renderRightPanelContent = () => {
if (isLoading) {
return <div className="loading">読み込み中...</div>;
}
if (errorMessage) {
return <div className="error">{errorMessage}</div>;
}
if (folderItems.length > 0) {
return <FolderCardGrid items={folderItems} onItemClick={handleFolderItemClick} />;
}
return null;
};
return (
<div className="app">
{!isAuthenticated ? (
<Login onLogin={handleLogin} />
) : (
<MainLayout
userName={userName}
userDisplayName={userDisplayName}
userFullPath={userFullPath}
onWorkspaceClick={handleWorkspaceClick}
rightPanelContent={renderRightPanelContent()}
/>
)}
</div>
);
}
export default App;
login.tsx
特に何もありません。ユーザ情報は他のパネルで使うのでAppに持たせます。
login.tsx
import { useState, FormEvent } from 'react';
import styles from './Login.module.css';
interface LoginProps {
onLogin: (username: string, password: string) => Promise<void>;
}
export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError('ユーザー名とパスワードを入力してください');
return;
}
setError('');
setIsLoading(true);
try {
await onLogin(username, password);
} catch (err) {
setError('ログインに失敗しました');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.loginContainer}>
<div className={styles.loginBox}>
<h1 className={styles.title}>WebFOCUS WebApp</h1>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<label htmlFor="username">ユーザー名</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
/>
</div>
<div className={styles.inputGroup}>
<label htmlFor="password">パスワード</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" disabled={isLoading} className={styles.button}>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
</form>
</div>
</div>
);
};
MainLayout.tsx:**
Header、LeftPanel、RightPanel、LeftPanelのグリッド構造
MainLayout.tsx
import { ReactNode } from 'react';
import Header from './Header';
import Footer from './Footer';
import LeftPanel from '../Panel/LeftPanel';
import RightPanel from '../Panel/RightPanel';
import styles from './MainLayout.module.css';
interface MainLayoutProps {
userName: string | null;
userDisplayName: string | null;
userFullPath: string | null;
leftPanelContent?: ReactNode;
rightPanelContent?: ReactNode;
onWorkspaceClick?: () => void;
}
const MainLayout: React.FC<MainLayoutProps> = ({
userName,
userDisplayName,
userFullPath,
leftPanelContent,
rightPanelContent,
onWorkspaceClick
}) => {
return (
<div className={styles.container}>
<Header
userName={userName}
userDisplayName={userDisplayName}
userFullPath={userFullPath}
/>
<div className={styles.content}>
<LeftPanel onWorkspaceClick={onWorkspaceClick}>
{leftPanelContent}
</LeftPanel>
<RightPanel>
{rightPanelContent}
</RightPanel>
</div>
<Footer />
</div>
);
};
export default MainLayout;
header.tsx:**
ユーザ情報を雑に表示
header.tsx
import styles from './Header.module.css';
interface HeaderProps {
userName: string | null;
userDisplayName: string | null;
userFullPath: string | null;
}
const Header: React.FC<HeaderProps> = ({ userName, userDisplayName, userFullPath }) => {
return (
<header className={styles.header}>
<div className={styles.logo}>WebFOCUS WebApp</div>
<div className={styles.userInfo}>
<div className={styles.userDetail}>
<span className={styles.label}>ユーザーID:</span>
<span className={styles.value}>{userName || '-'}</span>
</div>
<div className={styles.userDetail}>
<span className={styles.label}>表示名:</span>
<span className={styles.value}>{userDisplayName || '-'}</span>
</div>
<div className={styles.userDetail}>
<span className={styles.label}>パス:</span>
<span className={styles.value}>{userFullPath || '-'}</span>
</div>
</div>
</header>
);
};
export default Header;
LeftPanel.tsx
ワークスペースのクリックイベントだけ実装しています
leftpanel.tsx
import { ReactNode } from 'react';
import styles from './LeftPanel.module.css';
interface LeftPanelProps {
children?: ReactNode;
onWorkspaceClick?: () => void;
}
const LeftPanel: React.FC<LeftPanelProps> = ({ children, onWorkspaceClick }) => {
return (
<div className={styles.leftPanel}>
<h2 className={styles.title}>メニュー</h2>
<nav className={styles.nav}>
<ul>
<li><a href="#" onClick={(e) => { e.preventDefault(); }}>ダッシュボード</a></li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (onWorkspaceClick) onWorkspaceClick();
}}
>
ワークスペース
</a>
</li>
<li><a href="#" onClick={(e) => { e.preventDefault(); }}>アプリケーション</a></li>
<li><a href="#" onClick={(e) => { e.preventDefault(); }}>設定</a></li>
</ul>
</nav>
{children}
</div>
);
};
export default LeftPanel;
rightPanel.tsx
childrenが入るまでのつなぎ
rightPanel.tsx
import { ReactNode } from 'react';
import styles from './RightPanel.module.css';
interface RightPanelProps {
children?: ReactNode;
}
const RightPanel: React.FC<RightPanelProps> = ({ children }) => {
return (
<div className={styles.rightPanel}>
{children || (
<div className={styles.welcome}>
<h1>WebFOCUS WebAppへようこそ</h1>
<p>左側のメニューからアクションを選択してください。</p>
</div>
)}
</div>
);
};
export default RightPanel;
FolderCardGrid.tsx
Folderカードを並べるだけ
FolderCardGrid.tsx
import { FolderItem } from '../../services/WebFocusService';
import FolderCard from './FolderCard';
import styles from './FolderCardGrid.module.css';
interface FolderCardGridProps {
items: FolderItem[];
onItemClick?: (item: FolderItem) => void;
}
const FolderCardGrid: React.FC<FolderCardGridProps> = ({ items, onItemClick }) => {
if (!items.length) {
return <div className={styles.empty}>アイテムはありません</div>;
}
return (
<div className={styles.grid}>
{items.map((item, index) => (
<div className={styles.gridItem} key={item.fullPath || index}>
<FolderCard item={item} onClick={onItemClick} />
</div>
))}
</div>
);
};
export default FolderCardGrid;
FolderCard.tsx
カード型UI。フォルダ以外も表示するように改修予定。
FolderCard.tsx
import { FolderItem } from '../../services/WebFocusService';
import styles from './FolderCard.module.css';
interface FolderCardProps {
item: FolderItem;
onClick?: (item: FolderItem) => void;
}
const FolderCard: React.FC<FolderCardProps> = ({ item, onClick }) => {
const formatDate = (timestamp: string) => {
try {
const date = new Date(parseInt(timestamp));
return date.toLocaleString();
} catch {
return 'N/A';
}
};
const handleClick = () => {
if (onClick) {
onClick(item);
}
};
return (
<div className={styles.card} onClick={handleClick}>
<div className={styles.cardHeader}>
<img
src={item.thumbPath || '/folder-icon.svg'}
alt={item.typeDescription}
className={styles.icon}
/>
<h3 className={styles.title}>{item.name}</h3>
</div>
<div className={styles.cardBody}>
<p className={styles.description}>{item.description || item.name}</p>
<div className={styles.details}>
<span className={styles.detail}>
<strong>タイプ:</strong> {item.typeDescription}
</span>
<span className={styles.detail}>
<strong>作成者:</strong> {item.createdBy}
</span>
<span className={styles.detail}>
<strong>最終更新:</strong> {formatDate(item.lastModified)}
</span>
</div>
</div>
</div>
);
};
export default FolderCard;
Discussion