1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostmanAdvent Calendar 2024

Day 9

APIレスポンスのデータを3Dマップ上に表示する

Last updated at Posted at 2024-12-09

この記事は Postman Advent Calendar 2024 の9日目の記事です。

Postman のビジュアライズ機能と制限事項

Postman には、送信したリクエストのレスポンスを HTML テンプレートと JavaScript コードを使って可視化することができるビジュアライズ機能があります。ビジュアライズ機能については、昨年に次の記事でも紹介しました。

ビジュアライズ機能では、外部の JavaScript やスタイルシートファイルも読み込んで使えるため、基本的にはブラウザで動作できるものは何でも動作します。私は普段 Mapbox GL JS のようなウェブ 3D マップライブラリをよく扱うので、ビジュアライズ機能を使って API レスポンスを 3D マップ上に可視化してみたい、と前から思っていました。

しかし、ビジュアライズ機能ではユーザー生成コンテンツを安全に表示する仕組み上、コンテンツセキュリティーポリシー (CSP) 制限付きの iframe サンドボックスでコンテンツ環境を分離しており、blob: ソースのコンテンツが使えない、eval() が使えない、ウェブワーカーや IndexedDB が使えない、などの制限があります。

Mapbox GL JS は blob: ソースやウェブワーカーを使用してるため、これでは Postman ビジュアライズ機能では使えません。ちなみにウェブ 3D マップライブラリでは、UI 操作に性能上の影響がないようにマップデータのローディングをウェブワーカーで実行する形が一般的なので、Google Maps や CesiumJS も同じ理由で使えません。

そんな中、この件について色々な回避策を試した結果を、先日 CSP で制限されたページで Mapbox GL JS を使うにはというブログ記事にまとめました。記事中、特殊な例としてウェブワーカーの利用ができない環境について取り上げましたが、Postman のビジュアライズ機能の環境がまさにそれです。

というわけで、以下では Postman に埋め込んだ Mapbox GL JS の 3D マップ上に、API レスポンスのデータを可視化する具体的な手順を見ていきましょう。

ホテルの空室情報を 3D マップ上に表示する

ここでは、楽天トラベル空室検索 API で取得したホテル情報をマップ上に表示することを目指します。あらかじめ楽天アカウントで楽天ウェブサービスにログインし、新規アプリ登録を行なって「アプリ ID」を取得しておきましょう。

また、Mapbox アクセストークンも必要ですので、Mapbox アカウントにて Mapbox アカウントを作成して、アクセストークンを作成しておきます。

本記事で扱う API リクエスト、およびそれを含む Postman コレクションはこちらです。すぐに試してみたい方は、このコレクションを自分のワークスペースにフォークして、楽天アプリ ID、Mapbox アクセストークンを指定すれば動かすことができます。

それでは、このコレクションの内容を簡単に説明していきます。

コレクション変数

コレクションで定義されている変数です。rakuten-appidmapbox-token の現在値は空になっているので、自分で取得してきた値を入れて、保存してください。mapbox-*-url の値は後述のスクリプトで使われます。

変数
rakuten-appid 数字で構成される楽天アプリ ID
mapbox-token pk. で始まる Mapbox アクセストークン
mapbox-css-url https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.css
mapbox-js-url https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js
mapbox-worker-url https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js

エンドポイント

楽天トラベル+Mapbox という API リクエストを見てください。ホテルの空室情報を検索するエンドポイントです。

GET https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426

クエリパラメーター

検索条件については楽天トラベル空室検索 API をご覧いただきたいと思いますが、ここでは東京駅付近から半径1km圏内に絞って空室を検索しています。チェックイン・チェックアウト日を指定していませんが、デフォルトで当日チェックイン、翌日チェックアウトの指定になります。

キー
format json
latitude 35.6814
longitude 139.7670
datumType 1
searchRadius 1
applicationId {{rakuten-appid}}
hotelThumbnailSize 2

Pre-request スクリプト

mapbox-worker-url から取得したウェブワーカースクリプトを変数 worker-script に設定しています。

pm.sendRequest(pm.collectionVariables.get('mapbox-worker-url')).then(response => {
    pm.variables.set('worker-script', response.text());
});

Post-response スクリプト

HTML テンプレート部分は、CSP で制限されたページで Mapbox GL JS を使うにはの記事に準じたもので、擬似 Worker クラスを作って本来は動かせないはずのウェブワーカーコードをメインスレッドで動かすテクニックを使っています。これに加えて、テンプレート部の最後で各ホテルのサムネイル画像、名前、宿泊料金を表示するポップアップを作成しているのと、Post-response スクリプトの最後でレスポンスのデータから必要なデータを取り出して、Visualizer を呼び出しているのが新しい部分です。

const template = `
<link href="${pm.collectionVariables.get('mapbox-css-url')}" rel="stylesheet">
<script src="${pm.collectionVariables.get('mapbox-js-url')}"></script>
<div id="map" style="width: 100%; height: 100%;"></div>
<script>
mapboxgl.workerClass = (function(window) {
  const WW_CONTEXT_WHITELIST = [
    'createImageBitmap', 'fetch', 'ImageBitmap', 'ImageData',
    'OffscreenCanvas', 'Request', 'AbortController'
  ];

  return function() {
    const me = this;
    let worker_context;

    // Allow main thread to specify event listeners
    const ui_listeners = {};
    this.addEventListener = (event_name, fn) => {
      // listen for events from worker thread
      if (!ui_listeners[event_name])
        ui_listeners[event_name] = [];
      ui_listeners[event_name].push(fn);
    };

    // onmessage handler
    this.addEventListener('message', e => {
      if (typeof me.onmessage !== 'undefined') {
        me.onmessage(e);
      }
    });

    function WorkerGlobalScope() {}

    /**** Worker context accessible to worker *****/
    function WorkerContext() {
      const worker_listeners = {};
      this.addEventListener = (event_name, fn) => {
        // listen for events from UI thread
        if (!worker_listeners[event_name])
          worker_listeners[event_name] = [];
        worker_listeners[event_name].push(fn);
      };

      // onmessage handler
      this.addEventListener('message', e => {
        if (typeof worker_context.onmessage !== 'undefined') {
          try {
            worker_context.onmessage(e);
          } catch (error) {
            triggerEvent(ui_listeners, 'error', error, true);
          }
        }
      });

      this.postMessage = msg => {
        triggerEvent(ui_listeners, 'message', msg);
      };

      this.__processPostMessage = msg => {
        triggerEvent(worker_listeners, 'message', msg);
      };

      this.close = () => {};

      // window context
      for (const p of WW_CONTEXT_WHITELIST) {
        if (typeof window[p] === 'function' && !window[p].prototype) {
          this[p] = window[p].bind(window);
        } else {
          this[p] = window[p];
        }
      }
      this.WorkerGlobalScope = WorkerGlobalScope;
    }
    WorkerContext.prototype = new WorkerGlobalScope();
    worker_context = new WorkerContext();

    this.postMessage = msg => {
      worker_context.__processPostMessage(msg);
    };

    this.terminate = () => {};

    function triggerEvent(listeners_map, event_name, event_data, no_wrapping) {
      const event_obj = no_wrapping ? event_data : {data: event_data};

      if (!listeners_map[event_name]) return;
      for (const listener of listeners_map[event_name]) {
        listener(event_obj);
      }
    }

    const mask = {};

    // worker context
    for (const p in worker_context) {
      mask[p] = worker_context[p];
    }
    // set self context
    mask['self'] = worker_context;

    with(mask) {
      ${pm.variables.get('worker-script')}
    }
  }
})(window);

mapboxgl.accessToken = '${pm.collectionVariables.get('mapbox-token')}';

const map = new mapboxgl.Map({
  container: 'map',
  center: [139.7670, 35.6814],
  zoom: 16,
  pitch: 60
});
pm.getData((error, {hotels}) => {
    for (const hotel of hotels) {
        const html = [
            \`<div style="width: 90px">\`,
            \`<img src="\${hotel.thumbnail}"><br>\`,
            \`\${hotel.name}<br>\`,
            \`<a href="\${hotel.plans}" style="color: red; font-weight: bold">\`,
            \`\${hotel.price.toLocaleString()}\\u5186\\u301C\`,
            \`</a>\`,
            \`</div>\`,
        ].join('');
        const popup = new mapboxgl.Popup()
            .setLngLat(hotel.lngLat)
            .setHTML(html)
            .addTo(map);
    }
});
</script>
`;

const hotels = pm.response.json().hotels.map(item => {
    const hotelInfo = item.hotel[0].hotelBasicInfo;
    return {
        id: hotelInfo.hotelNo,
        name: hotelInfo.hotelName,
        plans: hotelInfo.planListUrl,
        lngLat: [hotelInfo.longitude, hotelInfo.latitude],
        thumbnail: hotelInfo.hotelThumbnailUrl,
        price: item.hotel[1].roomInfo[1].dailyCharge.total
    }
});
pm.visualizer.set(template, {hotels});

ということで、仕上がりはこんな感じです。マップをスクロールして、空室のあるホテルの位置と宿泊料金を確認することができます!

Screenshot 2024-12-09 at 10.56.05 PM.png

ホテルの空室情報を Mini Tokyo 3D 上に表示する

Mapbox GL JS でこれができるということは、Mapbox GL JS をベースにしている Mini Tokyo 3D でもできるということで、こちらの API リクエストも作ってみました。

Mini Tokyo 3D のマップ上では、リアルタイムで列車や航空機が動くので、リアルタイムのホテル検索もリアリティが増しますね!

Screenshot 2024-12-09 at 11.25.01 PM.png

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?