スマホアプリ

機能

お薬手帳に印字されたQRコードを読み取って処方情報をGoogleカレンダーに設定するアプリ。
Googleカレンダーへの服薬予定書き込みはGAS(Google Apps Script)で作成したサーバーアプリ(A1)が行う。このサーバーアプリはWebAPIを実装しており、スマホアプリはそのWebAPIを叩くことでGoogleカレンダーへの書き込みを実現している。

開発環境


Monaca
Cordovaプラグイン9.0.0
JS/CSSコンポーネント
  Cordova (PhoneGap) Loader バージョン:1.0.0
  jQuery (Monaca Version) バージョン:3.3.1
  Monaca Core Utility バージョン:2.0.7

index.html


<!DOCTYPE HTML>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'">
  <script src="components/loader.js"></script>
  <script src="lib/onsenui/js/onsenui.min.js"></script>

  <link rel="stylesheet" href="components/loader.css">
  <link rel="stylesheet" href="lib/onsenui/css/onsenui.css">
  <link rel="stylesheet" href="lib/onsenui/css/onsen-css-components.css">
  <link rel="stylesheet" href="css/style.css">

  <script>

    if (ons.platform.isIPhoneX()) {
      document.documentElement.setAttribute('onsflag-iphonex-portrait', '');
      document.documentElement.setAttribute('onsflag-iphonex-landscape', '');
    }

    // Bufferクラス
    class Buffer {
      constructor(str) {
          this.data = str;
      }
      get() {
        return this.data.trim().split('\r\n');
      }
      append(str) {
          this.data += str;
      }
    }
    var buf = new Buffer('');

    function scanBarcode() {
      cordova.plugins.barcodeScanner.scan(
        function (result) {
          alert("We got a barcode\n" +
                "Result: " + result.text + "\n" +
                "Format: " + result.format + "\n" +
                "Cancelled: " + result.cancelled);
          buf.append(result.text);
          var data = buf.get().join('\n');
          $('#data').html('<pre>' + data + '</pre>');
        },
        function (error) {
          alert("Scanning failed: " + error);
        },
        {
          preferFrontCamera : false, // iOS and Android
          showFlipCameraButton : true, // iOS and Android
          showTorchButton : true, // iOS and Android
          torchOn: true, // Android, launch with the torch switched on (if available)
          saveHistory: true, // Android, save scan history (default false)
          prompt : "Place a barcode inside the scan area", // Android
          resultDisplayDuration: 500, // Android, display scanned text for X ms. 0 suppresses it entirely, default 1500
          formats : "QR_CODE,PDF_417", // default: all but PDF_417 and RSS_EXPANDED
          orientation : "landscape", // Android only (portrait|landscape), default unset so it rotates with the device
          disableAnimations : true, // iOS
          disableSuccessBeep: false // iOS and Android
        }
      );
    }
    $(document).on('click','#scan',function(){
      scanBarcode();
    });
    $(document).on('click','#send',function(){
      var json = {
        "drugs": buf.get()
      }
      var json_text = JSON.stringify(json, null, ' ');
      $('#json').html('<pre>' + json_text + '</pre>');
      $.ajax({
          url: 'https://script.google.com/macros/s/AKfy.............................arP/exec',
          type: 'POST',
          data: JSON.stringify(json),
          contentType: 'application/json; charset=utf-8',
          dataType: 'json',
          async: false,
      })
      // Ajaxリクエストが成功した場合
      .done( (data, textStatus, errorThrown) => {
          $('#data').html(textStatus);
          alert('データが送信されました');
          $('#data').html('');
      })
      // Ajaxリクエストが失敗した場合
      .fail( (data, textStatus, errorThrown) => {
          alert('データ送信に失敗しました:data=' + JSON.stringify(data, null, ' '));
          $('#data').html('<pre>' + JSON.stringify(data, null, ' ') + '</pre>');
      })
    });
    
  </script>
</head>
<body>
  <ons-navigator id="navigator" page="page1.html"></ons-navigator>
  <ons-template id="page1.html">
    <ons-page id="first-page">
      <ons-toolbar>
        <div class="center">QRコード読み取り</div>
      </ons-toolbar>

      <div style="text-align: center">
        <p>このボタンをクリックしてQRコードを読み取る</p>
        <ons-button id="scan" modifier="large--cta">Scan</ons-button>
      </div>
      <div id="data"></div>
      <div style="text-align: center">
        <p>このボタンをクリックしてJSON形式に変換する</p>
        <ons-button id="send" modifier="large--cta">Send</ons-button>
      </div>
      <div id="json"></div>
    </ons-page>
  </ons-template>
</body>
</html>

リスト1.index.html 

実行結果と問題点


このアプリでQRコードを読み取り、WebAPIを叩いて処方情報をサーバーアプリへ送信すると、処理自体は成功する(Googleカレンダーに服用予定が設定される)が、スマホにはiOSの場合に限ってエラーが返ってくる。Androidではこのエラーは発生しない。

図1.エラーメッセージ

エラーコードは
 {"readyState":0,"status":0,"statusText":"error"}
になっている。サーバー側ではなくブラウザからエラーが送られてくる場合にstatusが"0"になる。その典型的な例はタイムアウトであるらしいが、今回はサーバー側の処理は成功しているので、タイムアウトではなさそうだ。
他にも Content Security Policy が関係する問題でこの現象が現れる場合があるらしい。ここには
A Status Code of 0 means "the browser refused to honor the request." Generally, this might happen because of a Content Security Policy, a pre-flight check failure, or because the site is not in the same network as the Internet (most browsers differentiate between local and public internet connections, and restrict public internet from reaching private networks).
ステータスコード"0"は、「ブラウザがリクエストの受け入れを拒否した」ことを意味します。一般に、これはコンテンツセキュリティポリシー、プリフライトチェックの失敗、またはサイトがインターネットと同じネットワークにないために発生する可能性があります(ほとんどのブラウザはローカルとパブリックのインターネット接続を区別し、パブリックインターネットがプライベートネットワークに到達することを制限します)。
と書かれている。いわゆる Cross-Origin Resource Sharing (CORS) 問題が絡んでいるのかもしれない。

ところが、このサイトによれば、GASで開発したWebAPIは CORS に対応しているらしい。ただし、JSONデータをまるごとPOSTする場合はエラーになるとのこと。JSONデータをポストする場合はcontent-typeがapplication/jsonとなり、その場合はクロスドメインアクセスが可能か確認するリクエスト(preflightリクエスト)が発生する。preflightリクエストは、OPTIONSメソッドを使って本来送信したいリクエストが安全かどうかをサーバー側へ確認するものであるが、GASはOPTIONSメソッドをサポートしていないためエラーが発生するらしい。

下記は、iPhoneから送信されたHTTPリクエスト(POSTメッセージ)である。

{
 "parameter": {},
 "contextPath": "",
 "contentLength": 586,
 "queryString": "",
 "parameters": {},
 "postData": {
  "type": "application/json",
  "length": 586,
  "contents": "{\"drugs\":[\"JAHISTC04,1\",\"1,山下 浩介,1,19701206,*******,岡山県**市***丁目*−*,***-**-****,,,,ヤマシタ コウスケ\",\"2,1,********アレルギー2,1\",\"2,9,********禁忌2,1\",\"5,20190426,1\",\"11,医療法人 オルカ医院,13,1,1234567,1130021,東京都文京区本駒込2−28−16,03-3946-0001,1\",\"15,森久 諒子,03-3946-0001,1\",\"201,1,クラビット細粒10% 100mg(レボフロキサシンとして),3,g,2,621925901,1\",\"301,1,【1日3回毎食後に】,1,日分,1,1,,1\"]}",
  "name": "postData"
 }
}
リスト2.iPhoneからのHTTPリクエスト

確かに application/json が送られている。

ブラウザからの実行


下記は、スマホアプリと同じことをするブラウザ上で稼働するHTMLである。
<!--
  A1-test
  Google Calendar にイベントを登録する(jQuery版)
  semi2018kumw  
-->
<!DOCTYPE html>
<head>
 <meta charset="UTF-8">
 <title>Googleカレンダーにイベントを登録(jQuery版)</title>
 <script
  src="https://code.jquery.com/jquery-3.4.1.min.js"
  integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
  crossorigin="anonymous"></script>
 <script>
  $(document).ready(function(){
   $('#btn1').on('click', function() {
    alert("クリックされました");
   });
  });
  
  // Bufferクラス
  class Buffer {
   constructor(str) {
    this.data = str;
   }
   get() {
    return this.data.trim().split('\n');
   }
   append(str) {
    this.data += str;
   }
  }
  var buf = new Buffer('');
  
  $(document).on('click','#send',function(){
   var description = $('#description').val();
   buf.append(description);
   var json = {
    "drugs": buf.get()
   }
   var json_text = JSON.stringify(json, null, ' ');
   $('#json').html('<pre>' + json_text + '</pre>');

   $.ajax({
    url: 'https://script.google.com/macros/s/AKf..................jarP/exec',
    type: 'POST',
    data: JSON.stringify(json),
    dataType: 'json'
   })
   // Ajaxリクエストが成功した場合
   .done( (data, textStatus, errorThrown) => {
    $('#data').html(textStatus);
    alert('データが送信されました');
    $('#data').html('<pre>' + JSON.stringify(data, null, ' ') + '</pre>');
   })
   // Ajaxリクエストが失敗した場合
   .fail( (data, textStatus, errorThrown) => {
    alert('データ送信に失敗しました:data=' + JSON.stringify(data, null, ' '));
    $('#data').html('<pre>' + JSON.stringify(data, null, ' ') + '</pre>');
   })
  });

 </script>
</head>
<body>
  <h1>A1-test</h1>
  <!-- webapi_googleCal_createEvent -->
  <button id="btn1">ボタン1</button>
    <table>
  <tr>
   <th align="left">お薬の内容:</th>
   <td>
    <textarea id="description" rows="15", cols="120">
JAHISTC04,1
1,山下 浩介,1,19701206,*******,岡山県**市***丁目*?*,***-**-****,,,,ヤマシタ コウスケ
2,1,********アレルギー2,1
2,9,********禁忌2,1
5,20190426,1
11,医療法人 オルカ医院,13,1,1234567,1130021,東京都文京区本駒込2?28?16,03-3946-0001,1
15,森久 諒子,03-3946-0001,1
201,1,クラビット細粒10% 100mg(レボフロキサシンとして),3,g,2,621925901,1
301,1,【1日3回毎食後に】,1,日分,1,1,,1
    </textarea>
   </td>
  </tr>
    </table>
    <p>
      <button id="send">送信</button>
    </p>
 <div id="data"></div>
 <div id="json"></div>
</body>
</html>
リスト3.処方情報をGASサーバへ送信するHTML

ここで、本来QRコードから読み取る処方情報はTEXTAREAから入力するようになっている(デフォルトとして処方データを設定済)。
これを実行したとき、処理は正常に終了(「データが送信されました」と表示される)し、そのときサーバへ送られるHTTPリクエストは次のようになっていた。

{
 "parameter": {
  "{\"drugs\":[\"JAHISTC04,1\",\"1,,1,19701206,*******,?,***-**-****,,,,\",\"2,1,,1\",\"2,9,,1\",\"5,20190426,1\",\"11,,13,1,1234567,1130021,??,03-3946-0001,1\",\"15,,03-3946-0001,1\",\"201,1,,3,,2,621925901,1\",\"301,1,,1,,1,1,,1\"]}": ""
 },
 "contextPath": "",
 "contentLength": 580,
 "queryString": "",
 "parameters": {
  "{\"drugs\":[\"JAHISTC04,1\",\"1,,1,19701206,*******,?,***-**-****,,,,\",\"2,1,,1\",\"2,9,,1\",\"5,20190426,1\",\"11,,13,1,1234567,1130021,??,03-3946-0001,1\",\"15,,03-3946-0001,1\",\"201,1,,3,,2,621925901,1\",\"301,1,,1,,1,1,,1\"]}": [
   ""
  ]
 },
 "postData": {
  "type": "application/x-www-form-urlencoded",
  "length": 580,
  "contents": "{\"drugs\":[\"JAHISTC04,1\",\"1,山下 浩介,1,19701206,*******,岡山県**市***丁目*?*,***-**-****,,,,ヤマシタ コウスケ\",\"2,1,********アレルギー2,1\",\"2,9,********禁忌2,1\",\"5,20190426,1\",\"11,医療法人 オルカ医院,13,1,1234567,1130021,東京都文京区本駒込2?28?16,03-3946-0001,1\",\"15,森久 諒子,03-3946-0001,1\",\"201,1,クラビット細粒10% 100mg(レボフロキサシンとして),3,g,2,621925901,1\",\"301,1,【1日3回毎食後に】,1,日分,1,1,,1\"]}",
  "name": "postData"
 }
}
リスト4.HTMLによるHTTPリクエスト

この場合、postDataのtypeはapplication/x-www-form-urlencodedになっており、application/jsonではなかった。

しかしながら、リスト1において$.ajaxのパラメラの
contentType: 'application/json; charset=utf-8',
を削除しても、エラーは消えなかった(ただしpostDataのtypeはapplication/x-www-form-urlencodedに変わった)。

パケット解析


Chromeのデベロッパーツールを使ってリスト3による処方情報送信のパケット解析を行った。パケットは3つあった。

1. exec


General
Request URL: https://script.google.com/macros/s/AKfy.................arP/exec
Request Method: GET
Status Code: 302 
Remote Address: 216.58.196.238:443
Referrer Policy: no-referrer-when-downgrade

Response Headers
access-control-allow-origin: *
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
cache-control: no-cache, no-store, max-age=0, must-revalidate
content-encoding: gzip
content-length: 425
content-security-policy: script-src 'report-sample' 'nonce-UyY3cDNlB84EJb0edBCu2g' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;object-src 'none';base-uri 'self';report-uri /cspreport
content-type: text/html; charset=UTF-8
date: Thu, 10 Oct 2019 02:32:09 GMT
expires: Mon, 01 Jan 1990 00:00:00 GMT
location: https://script.googleusercontent.com/macros/echo?user_content_key=hXXaL........oPMcn&lib=MVg....EdG
pragma: no-cache
server: GSE
status: 302
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block

Request Headers
Referer: http://172.16.108.7/~mtanaka/googleappscript/A1-test.html
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36

まず最初に送信されたのはGETメソッドのHTTPリクエストだった。宛先URLはGASで公開したアドレスである。Status Codeは302になっている。302というのはリクエストしたリソースが一時的に移動したときに返されるコードで、Response Headersのlocation:ヘッダに移動先のURLが示される。それは
https://script.googleusercontent.com/macros/echo?user_content_key=hXXaL........oPMcn&lib=MVg....EdG
となっており、これはGASのエンドポイントではない。
Response Headersの先頭行に
access-control-allow-origin: *
というヘッダがあるが、これは、すべてのオリジンからのリクエストコードにリソースへのアクセスをブラウザーに許可する指示である。すなわち、GASはCORSを許可している。

2. exec


2番目のHTTPリクエストでPOSTメソッドを使ってJSON形式のデータをサーバへ送っている。

General
Request URL: https://script.google.com/macros/s/AKfy......jarP/exec
Request Method: POST
Status Code: 302 
Remote Address: 216.58.196.238:443
Referrer Policy: no-referrer-when-downgrade

Response Headers
access-control-allow-origin: *
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
cache-control: no-cache, no-store, max-age=0, must-revalidate
content-encoding: gzip
content-length: 425
content-security-policy: script-src 'report-sample' 'nonce-UyY3cDNlB84EJb0edBCu2g' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;object-src 'none';base-uri 'self';report-uri /cspreport
content-type: text/html; charset=UTF-8
date: Thu, 10 Oct 2019 02:32:09 GMT
expires: Mon, 01 Jan 1990 00:00:00 GMT
location: https://script.googleusercontent.com/macros/echo?user_content_key=hXXa...............J1EdG
pragma: no-cache
server: GSE
status: 302
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block

Request Headers
Accept: application/json, text/javascript, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://172.16.108.7
Referer: http://172.16.108.7/~mtanaka/googleappscript/A1-test.html
Sec-Fetch-Mode: cors
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36

Form Data
{
 "drugs": [
  "1,鈴木 太郎,1,S330303,,東京都港区新橋1丁目,03-1234-1234,,B ,63.7,スズキ タロウ,",
  "2,1,乳製品,1,",
  "2,2,セフェム系(発熱),1,",
  "5,20190808,1,",
  "11,医療法人 オルカ医院,13,1,1234567,1130021,東京都文京区本駒込28-26,03-3946-0001,1,",
  "15,工業会 次郎,03-4567-4567,1,",
  "201,1,ノルバスク錠2.5mg,1,錠,1,,1,",
  "201,1,クラビット細粒10% 100mg(レボフロキシンとして),3,g,2,621925901,1,",
  "301,1,【1日3回毎食後に】,1,日分,1,1,,1,",
  "201,1,ペンニードル30Gテーパー,14,本,1,,1,",
  "201,1,バイアグラ,1,錠,1,,1,",
  "301,1,毎食後服用,3,日分,1,1,,1"
 ]
}

3. echo?user_content_key=hXXaL... 


3番目のHTTPリクエストはGASのエンドポイントではなく
https://script.googleusercontent.com/macros/echo?user_content_key=hXXa...
へHTTPリクエストを送信している。

General
Request URL: https://script.googleusercontent.com/macros/echo?user_content_key=hXXa..........J1EdG
Request Method: GET
Status Code: 200 
Remote Address: 216.58.196.225:443
Referrer Policy: no-referrer-when-downgrade

Response Headers
access-control-allow-origin: *
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
cache-control: no-cache, no-store, max-age=0, must-revalidate
content-encoding: gzip
content-type: application/json; charset=utf-8
date: Thu, 10 Oct 2019 02:32:09 GMT
expires: Mon, 01 Jan 1990 00:00:00 GMT
pragma: no-cache
server: GSE
status: 200
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block

Request Headers
Accept: application/json, text/javascript, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: null
Referer: http://172.16.108.7/~mtanaka/googleappscript/A1-test.html
Sec-Fetch-Mode: cors
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36

Query String Parameters
user_content_key: hXXa..................J1EdG

Request Headers中に
Sec-Fetch-Mode: cors
というヘッダがあるが、これはクロスオリジンリクエストを許可することを意味する。

これ、関係あるかも


あるサイトに今回の現象とよく似たケースの記述があった。原因はCORSではなくGASのスクリプト内で発生したエラーがこの現象を引き起こすというのである。
しかし、リスト3のHTMLによるブラウザからのリクエストではこの現象は発生せず、Monacaで開発したリスト1のスマホアプリをiPhoneで実行した場合に発生する(Androidの場合はうまくいく)。しかも、Googleカレンダーに服薬予定を書き込むという処理自体は成功している(ように見える)。

以上から考えてiOS特有の仕様に関係する現象と思われる

0 件のコメント:

コメントを投稿