GASのUrlFetchAppをラッピングして生産性を向上した話

この記事は、
「**GASのUrlFetchAppでHTTP通信のコード書くの、もうやめたい**」
と常日頃から思っている、とあるエンジニアのつぶやきです。

**毎回書くの、そろそろしんどくないですか？**

しかも、Googleさんは仕様変更がしばしば行われますし。
**UrlFetchAppの仕様が変わったらどうなります？**
全部修正するのめっちゃ大変だし無駄なコストじゃないですか？

少なくとも、私は面倒くさいです。しんどいです。
コードが長いです。可読性も下がります。メンテ大変です。嫌です。しんどいです。(ブツブツ...)

基礎や標準を知るのはとても良いことです。素晴らしいです。
導入として「UrlFetchAppを使うんだよ」というのは良いことです。大賛成です。

ただ、書き慣れて、色んなコードを作って行く内に「また書くの？」って思うようになる日が来ました。
...というわけで、クラス化して楽をしています。共有します。

# 通常の書き方

HTTP通信を処理するのに、以下のようなコードを書いてました。
場合によって不要なものもありますが、使いそうなものコミコミで大体こんな感じです。

```javascript:UrlFetchAppによるHTTP通信(POST)

const headers = {
  // リクエストヘッダー
}

const payload = {
  // リクエストパラメータ
}

const options {
  'method': 'POST', // HTTPメソッド: 'GET' or 'POST' or 'UPDATE' or 'DELETE' or 'PUT'
  'headers': headers,
  'payload': payload,
  'followRedirects': false, // 自動リダイレクト: true or false
  'muteHttpExceptions': false // エラー停止: true or false
}

const url = 'https://...'
const response = UrlFetchApp.fetch(url, options) // HTTPリクエスト送出、レスポンス取得

// レスポンスボディ取得
const contentText = response.getContentText() // テキストで取得
const contet = response.getContent() // Rawで取得
const contentJson = JSON.parse(contentText) // JSON形式の場合はobjectに変換

// レスポンスヘッダーを取得したい場合はヘッダーを取得
const responseHeaders = response.getAllHeaders()
// 個別にヘッダーを取得
const location = responseHeaders['Location']

// Cookieがある場合はCookieを取得
const cookies = responseHeaders['Set-Cookie']
// 個別にCookieを取得
const getCookie = function(cookies, key) {
  for (const cookie of cookies) {
    // キーに一致する値を取得
    const values = cookie.split(';')
    for (const value of values) {
      const k = value.split('=')[0]
      const v = value.split('=')[1]
      if (k == key) {
        return v
      }
    }
  }
  return false
}
const sessionId = getCookie(cookies, 'JSESSIONID')
```

# 楽する書き方

**UrlFetchAppをラッピングしたクラス**を作成して簡略化しました。

上記のコードがこれ↓だけで済むようになりました。

```javascript:作成したクラスによるHTTP通信(POST)
const HttpClientFactory = HttpClient._HttpClientFactory // ライブラリからクラスを取得

// POSTリクエスト
const url = 'https://...'
const client = HttpClientFactory.create(url)
client.post(client.makeOptions({
 // リクエストヘッダー
}, {
 // リクエストパラメータ
}))

// レスポンスボディ取得
const contentText = client.contentText
const content = client.content
const contentJson = client.contentJson

// レスポンスヘッダー取得
const responseHeaders = client.responseHeaders
// 個別にヘッダーを取得
const location = client.location

// Cookieの取得
const cookies = client.cookies
// 個別にCookieを取得
const sessionId = client.getCookie('JSESSIONID')
```

まず、ファクトリクラスでHTTPクライアントのインスタンスを取得します。
1通信(=1つのURL)につき1インスタンスというイメージです。

makeOptions()でoptionsを構築します。
※ 毎回書いていた部分は内包しているので意識する必要がありません

後はget()、post()などHTTPメソッドに応じたメソッドを用意していますので、optionsを渡して呼び出します。
※ makeOptions(）を使わずに自分でoptionsを構築して渡せるようにもしてあります

UrlFetchApp.fetch()で返却されるresponseオブジェクトはHTTPクライアントのプロパティとして持っています。
また、response.getContentText()などのresponseオブジェクトのメソッドで得られる値もプロパティとして持っています。
※ get()、post()などの返却値にはresponseオブジェクトを返しているので、自分でresponseを処理できるようにもしてあります

つまり、**HTTPクライアント作って、リクエスト送って、結果を取り出すだけ**、ということです。

簡単です。しかも**仕様変更があったら、HTTPクライアントクラスを修正するだけ**で済みます。
楽ちんです。これでもうしんどくない。

# ソースコード

以下、HTTPクライアントとユーティリティのクラスのコードを掲載します。
ライブラリ化するなり、貼り付けて使うなり、煮るなり焼くなりご自由にお使いください。

それすらも楽したい人がいるかもしれないので、ライブラリとして公開してあります。
スクリプトID: M76SJKBq2dI2l6GQpMloxUQUdEkA87Gcz

楽をすることは素晴らしいです。

```javascript:HttpClient.gs
function _HttpClientFactory() {return HttpClientFactory}

/**
 * HttpClientのファクトリークラス
 */
class HttpClientFactory {
  
  /**
   * インスタンス生成
   * @param {string} url - リクエストURL
   * @return {HttpClient} - インスタンス
   */
  static create(url, queryParams=false) {
    return new HttpClient(url, queryParams)
  }
  
}

/**
 * HTTPクライアントクラス<br>
 * UrlFetchAppのラッパー
 */
class HttpClient {
  
  /**
   * コンストラクタ
   * @param {string} url - リクエスト先のURL
   * @param {object} queryParams - クエリパラメータのオブジェクト
   */
  constructor(url, queryParams=false) {
    this.url = url
    this.queryParams = queryParams
  }
  
  /**
   * クエリパラメータを設定（インスタンス生成後にセットする場合）
   * @param {object} queryParams - クエリパラメータのオブジェクト
   * @return {HttpClient} - 自身のインスタンス
   */
  setQueryParams(queryParams) {
    this.queryParams = queryParams
    return this
  }
  
  /**
   * デフォルトのリクエストパラメータを作成する
   * @param {object} headers - リクエストヘッダー
   * @param {object} payload - リクエストボディ
   * @param {bool} followRedirects - リダイレクトを自動処理するかのフラグ true:する、false:しない
   */
  makeOptions(headers=false, payload=false, followRedirects=false) {
    
    let options = {
      'followRedirects': followRedirects
    }
    
    if (headers) {
      options['headers'] = headers
    } else {
      options['headers'] = {}
    }
    
    if (payload) {
      options['payload'] = payload
    }
    
    return options
  }
  
  /**
   * GETリクエスト送信
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  get(options={}, responseConfigs={}) {
    return this.wrappedRequest('get', options, responseConfigs)
  }
  
  /**
   * POSTリクエスト送信
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  post(options={}, responseConfigs={}) {
    return this.wrappedRequest('post', options, responseConfigs)
  }
  
  /**
   * PUTリクエスト送信
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  put(options={}, responseConfigs={}) {
    return this.wrappedRequest('put', options, responseConfigs)
  }
  
  /**
   * DELETEリクエスト送信
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  delete(options={}, responseConfigs={}) {
    return this.wrappedRequest('delete', options, responseConfigs)
  }
  
  /**
   * リクエスト送信のラッピング関数
   * @param {string} method - HTTPメソッド
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  wrappedRequest(method, options={}, responseConfigs={}) {
    if (typeof options !== 'object') {
      options = {}
    }
    options['method'] = method
    
    return this.request(options, responseConfigs)
  }
  
  /**
   * リクエスト送信
   * @param {object} options - リクエストパラメータ
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  request(options, responseConfigs={}) {
    let url = this.url
    if (this.queryParams && Type.isObject(this.queryParams)) {
      url += '?' + UrlUtil.makeQueryString(this.queryParams)
    }
    
    options['muteHttpExceptions'] = false
    
    //console.log('************* request')
    //console.log(url)
    //console.log(options)
    
    this.response = UrlFetchApp.fetch(url, options)
    
    this.responseHeaders = this.response.getAllHeaders()
    //console.log(this.responseHeaders)
    
    // リダイレクト先
    this.location = this.responseHeaders['Location']
    
    // Cookie
    this.cookies = this.responseHeaders['Set-Cookie']
    //console.log(this.cookies)
    
    // HTTPレスポンスコード
    this.responseCode = this.response.getResponseCode()
    //console.log(this.responseCode)
    
    // レスポンス本文テキスト
    if (ObjectUtil.get(responseConfigs, 'charset')) {
      this.contentText = this.response.getContentText(responseConfigs['charset'])
    } else {
      this.contentText = this.response.getContentText()
    }
    //console.log(this.contentText)
    
    this.content = this.response.getContent()
    
    if (ObjectUtil.get(responseConfigs, 'contentType')) {
      this.contentBlob = this.response.getAs(contentType)
    } else {
      this.contentBlob = this.response.getBlob()
    }
    
    try {
      this.contentJson = JSON.parse(this.contentText)
    } catch (e) {
      this.contentJson = false
    }
    
    return this.response
  }
  
  /**
   * JSON形式のパラメータでPOSTリクエスト送信
   * @param {object} payload - リクエストボディ
   * @param {object} headers - リクエストヘッダー
   * @param {object} responseConfigs - レスポンスの設定オブジェクト
   * @return {HTTPResponse} - レスポンスオブジェクト
   */
  jsonPost(payload, headers={}, responseConfigs={}) {
    const options = {
      "headers": headers,
      "contentType" : "application/json",
      "payload" : JSON.stringify(payload)
    }
    
    return this.post(options, responseConfigs)
  }
  
  /**
   * クッキーの値を取得
   * @param {string} key - Cookieのキー
   * @return {string} - Cookieの値
   */
  getCookie(key) {
    return CookieUtil.getValue(this.cookies, key)
  }
}
```

```javascript:Utils.gs
/**
 * URLに関するユーティリティクラス
 */
class UrlUtil {
  
  static makeQueryString(paramsObj) {
    
    const q = []
    
    for (const key in paramsObj) {
      const val = paramsObj[key]
      if (val !== undefined) {
        q.push(key + '=' + encodeURIComponent(val))
      }
    }
    
    return q.join('&')
  }
}

/**
 * Cookieのユーティリティクラス
 */
class CookieUtil {
  /**
   * 値を抽出
   * @param {string} cookie - Cookieデータ（"name=value;")
   * @param {string} key - クッキーのキー
   * @return {string} - クッキーの値
   */
  static getValue(cookies, key=false) {
    if (!cookies || cookies == '') {
      return ''
    }
    
    if (key) {
      if (Array.isArray(cookies)) {
        for (const cookie of cookies) {
          // キーに一致する値を取得
          const values = cookie.split(';')
          for (const value of values) {
            const k = value.split('=')[0]
            const v = value.split('=')[1]
            if (k == key) {
              return v
            }
          }
        }
      }
      else {
        // キーに一致する値を取得
        const values = cookies.split(';')
        for (const value of values) {
          const k = value.split('=')[0]
          const v = value.split('=')[1]
          if (k == key) {
            return v
          }
        }
      }
      return false
    }
    else {
      // キー指定なしの場合は1つめの値を取得
      const fromIndex = cookies.indexOf('=')
      const toIndex = cookies.indexOf(';')
      return cookies.substring(fromIndex+1, toIndex)
    }
  }
}

/**
 * Objectのユーティリティクラス
 */
class ObjectUtil {
  /**
   * Objectから指定のキーの値を取得する。存在しないキーの場合は引数に渡したデフォルト値を取得する。
   * @param {Object} obj - 取得対象のオブジェクト
   * @param {string} key - キー値
   * @param {Object} defaultValue - キーが存在しなかった場合の値
   * @return {Object|bool} - キーが存在:値、キーが存在しない：defaultValue（未指定のデフォルト値はfalse）
   */
  static get(obj, key, defaultValue=false) {
    if (key in obj) {
      return obj[key]
    } else {
      return defaultValue
    }
  }
}
```