JavaScriptから使いやすいWebAPIをRPCでお手軽に


JavaScriptから使いやすいWebAPIを考える回。
WebAPIといえばRESTが有名ですが、今回はRPCを実装してみます。

## 基本的な使い方

``wfn.js``はローカル関数を実行するような感覚で、サーバ側の関数を実行できます。

```js:基本的な使い方
import API from './wfn.js'

const result = await API.plus(1, 2) //足し算するAPI
//結果は3
```

ここでAPIのplusという関数が実行されてますが、これは幻です。
裏で**関数名**と**引数**がHTTP送信されて、サーバ側で同名の関数が実行され、戻り値が返ってくる仕組みです。


基本的にGET送信ですが、**関数名が大文字から始まる**とPOST送信になります。

```js:POST送信
const result = await API.Save(file) //ファイルを保存するAPI
//結果はtrue or false
```

以上がJavaScriptからWebAPIにアクセスする最善の書き方だと思います。
サーバ側も同名の関数定義＋αで済むので、全体を通してシンプルで分かりやすいかと。

## 主な仕様
クライアント側の仕様

- クライアント側は、APIのURLを1つ決めるだけの簡単設定
- HTTPメソッドは``GET``と``POST``の2つ。関数名の先頭が大文字だと``POST``になる
- 引数には特別にファイルとform要素が渡せる

内部的な話
- 関数名は``PATH_INFO``として送る
- 引数は個別にJSONにして、クエリパラメータ``0=引数1&1=引数2``のように送る
- 送信方法はformと同じ``application/x-www-form-urlencoded``
  - ファイルを送る時は``multipart/form-data``になる
  - 単純なリクエストなのでCORS時にプリフライトが発生しない
- サーバからの戻り値はJSON
- 応答コードが200以外なら例外が発生する

## クライアント側のコード

クライアント側のコードは、APIのURLを設定するだけです。

```js:wfn.js
const apiurl = 'http://localhost/wfn'

export default new Proxy(wfn, {get: (_,fn) => wfn.bind(fn)})

async function wfn(...args){
    const url    = new URL(apiurl + '/' + encodeURIComponent(this))
    const option = {credentials:'include'}

    if(this.match(/^[A-Z]/)){
        option.method = 'POST'
        option.body   = new FormData()
        args.forEach((v, i) => {
            if(v instanceof HTMLFormElement){
                v = Object.fromEntries(new FormData(v))
            }
            if(!(v instanceof Blob)){
                v = JSON.stringify(v)
            }
            option.body.append(i, v)
        }
    }
    else{
        args.forEach((v, i) => url.searchParams.set(i, JSON.stringify(v)))
    }

    const response = await fetch(url, option)

    if(response.status !== 200){
        throw response
    }

    return response.json()
}
```
- 幻メソッドの仕組みはProxy

## サーバ側のコード

サーバ側のコードは自作する必要がありますが、仕様は単純なので実装も簡単です。
言語はなんでもいいですが、参考までにPHPのサンプルを紹介。

```php:wfn.php
class API{ // これが公開される関数
    static function plus(int $v1, int $v2): int{
        return $v1 + $v2;
    }

    static function Save(array $file): bool{
        return move_uploaded_file($file['tmp_name'], "./a.txt");
    }
}

function wfn(string $api){
    $fn     = basename($_SERVER['PATH_INFO']);
    $method = preg_match('/^[A-Z]/', $fn) ? 'POST' : 'GET'

    if($fn !== (new ReflectionMethod($api, $fn))->name){ //関数の存在と大文字小文字を確認
        throw new Exception('関数名の大文字小文字が違います');
    }
    if($method !== $_SERVER['REQUEST_METHOD']){
        throw new Exception('不正なメソッドです');
    }

    if($method === 'POST'){
        $args = [];
        for($i=0; $i<count($_POST)+count($_FILES); $i++){
            $args[] = $_FILES[$i] ?? json_decode($_POST[$i]);
        }
    }
    else{
        $args = array_map(fn($v) => json_decode($v), $_GET);
    }

    header('Content-Type: application/json');
    print json_encode( $api::$fn(...$args) );
}


try{
    wfn('API');
}
catch(Throwable $e){
    http_response_code(400);
}
```
セキュリティ面での話

- 任意の関数が実行できるのが一番マズイので注意
  - 上記の例だと、実行できる関数は指定クラスの静的メソッドに限定している
- POST時は許可オリジンか確認しましょう
- 一般的な話ですが、ネットから送られてくるデータは信用できないので注意しましょう
- [PHP限定] PHPは関数/メソッドの大文字小文字を区別しないので、きちんと確認する
- [PHP限定] アップされたファイルは必ず``move_uploaded_file()``を通してください


## メモ

```js: 
//複数送信の構想。Proxyのapplyを使う
API({関数1:[引数1]}, {関数2:[引数2]})

//引数に渡せるオブジェクトを汎用的に対処
v[Symbol.iterator]?.name === 'entries'
//ArrayとSetは'values'

//別サイトからのアクセスをブロック(same-origin, same-siteは許可)
if($_SERVER['HTTP_SEC_FETCH_SITE'] === 'cross-site' or $_SERVER['HTTP_SEC_FETCH_SITE'] === 'none'){ 
    exit;
}

//クロスオリジン対応。Cookieにも対応
if(isset($_SERVER['HTTP_ORIGIN'])){ //同一オリジンでGETの場合は送られてこない
    header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}"); //'cross-site'をブロックしていればこれでOK
    header("Access-Control-Allow-Credentials: true");
}

//is_ajax()
$_SERVER['HTTP_SEC_FETCH_DEST'] === 'empty'

```