JavaScriptから使いやすいWebAPIを考える回。
WebAPIといえばRESTが有名ですが、今回はRPCを実装してみます。
基本的な使い方
wfn.js
はローカル関数を実行するような感覚で、サーバ側の関数を実行できます。
基本的な使い方
import API from './wfn.js'
const result = await API.plus(1, 2) //足し算するAPI
//結果は3
ここでAPIのplusという関数が実行されてますが、これは幻です。
裏で関数名と引数がHTTP送信されて、サーバ側で同名の関数が実行され、戻り値が返ってくる仕組みです。
基本的にGET送信ですが、関数名が大文字から始まるとPOST送信になります。
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を設定するだけです。
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のサンプルを紹介。
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()
を通してください
メモ
//複数送信の構想。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'