Zenn
🌟

ReactでWebFOCUSのRESTful APIを使ったアプリケーション(実践編・その1)

に公開

ReactでWebFOCUSのRESTful APIを使ったアプリケーション(実践編・その1)

はじめに

WebFOCUSのRESTful APIを使ってWebアプリケーションを何度かに分けて作成します。

Reactは初心者なので見守るか優しくアドバイスください。

開発する機能

様々なIBIRS_actionを使った実験的なモデルです。

  • フォーム認証によるログイン
  • ワークスペースの資産の表示と編集と削除
  • アプリケーションディレクトリの資産の表示と編集と削除
  • 埋め込みアプリケーションを配置したダッシュボード
  • デザイナやエディタなどのWebFOCUSクライアントツールと連携
  • セキュリティセンターの簡易的なもの

が、どうにか形になれば良いと思っています。

開発環境

viteで、React + TypeScriptの環境を作りました。

https://github.com/shimokado/webfocus-rest-app

動作イメージ

シンプルなログイン画面

RESTAPP1

グリッドレイアウトのメイン画面

  • ヘッダーにユーザ情報を雑に表示
  • 左にナビゲーション
  • 右がメインの表示エリア
  • フッターはただの文字

RESTAPP1

ワークスペースをクリックしたらアイテムをカード型で表示

  • フォルダのクリックで掘り下げていくスタイル
  • WebFOCUSに登録されている画像とdescriptionを表示しても良い

RESTAPP1

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

ログインするとコメントできます