BacklogのAPIでファイルをアップロードしてみた

こんにちは。Backlog テクニカルサポートの立石です。

2022年のヌーラボ真夏のブログリレーでは API の基本的な説明や Gmail と Backlog API を使って課題を追加してみました(前回のブログはこちら)。

今回は、前回同様にお客様から API について以下のようなお声をいただくことがありましたので、タイトルにもあるように Backlog API を使ってファイルをアップロードしてみようと思います。

  • APIを使って課題にファイルを添付したいがファイルのアップロードの方法がよくわからない
  • API でファイルのアップロードをしようとしているがエラーが発生してファイルのアップロードができない

せっかくなので前回の Gmail から課題を追加したプログラムをベースに、そこから少し発展させて、Gmail に添付されたファイルを Backlog にアップロードさせる形でやってみようと思います。

ぜひ最後までお付き合いいただけますと幸いです。

ファイルのアップロードの仕組みを理解してみる

Backlog のファイルアップロード

Backlog API ではファイルのアップロードと課題の追加は分けて実行する必要があります。
まずは 添付ファイルの送信する API を使ってファイルをアップロードします。ファイルをアップロードすると一意の ID が発行されるので、その ID を 課題の追加の API のパラメーターとして引き渡すことにより課題にファイルが添付されるようになります。

ちょっとわかりづらいかもしれないので図にしてみました。

Backlog へは2回リクエストすることにはなりますが、処理を分けることでそれぞれ役割を分担できるのですっきりするのではないかと思います。

では、早速前回と同様に切り分けて順に解説していきます。

ファイルをアップロードしてみよう

前回は Gmail から課題を登録してみましたが、今回はそのメールにファイルが添付されていた場合を想定して作ってみましょう。

Gmail から添付ファイルを取得する

前回の件名やメールの本文を取得した方法と同様に、添付ファイルも Gmail専用の API を使って取得します。件名や本文が入っている messages の中に attachments として添付ファイルが格納されていますので、そちらから取得します。

for (let th in threads) {
  let thread = threads[th];
  let messages = thread.getMessages();

  for (let msg in messages) {
    let mail = messages[msg];

    let subject = mail.getSubject(); // 件名
    let body = mail.getPlainBody(); // 本文
    let attachments = mail.getAttachments(); // 添付ファイル
     .
     .
  }
}

添付ファイルは簡単に取得できましたね。

次はそのファイルを Backlog へアップロードしましょう。と言いたいのですが、アップロードするためには、ファイルをデータにして送信する必要があります。そのため、次はファイルをデータにしていく処理を解説していきます。

ファイルを準備しよう

ファイルのアップロードでは、ファイルの名前やどのような種類のファイルであるか、データの内容はどのようなものかを 1つのファイルデータとして送信するのですが、今回はファイルのデータを multipart/form-data という形式で送信します。

multipart/form-data について、あまり聞きなれない方がおられるかもしれませんが、これはフォーム内にある複数のデータを同時送信することを意味します。「複数のデータ」と書きましたが、ここでは「複数ファイルのデータ」と思っていただくと良いかと思います。

multipart/form-data は以下のようにContent-Typeに指定します。

Content-Type: multipart/form-data; boundary=Boundary01

ここで何か新しいものが出てきましたね。
「boundary」ってなんでしょうか?

「boundary」は直訳すると「境界」という意味になります。
ファイルのアップロードでいう「境界」は「境界線」と捉えると良いかもです。では何の境界線でしょうか?

先ほどファイルをアップロードするにはファイル名やファイルの種類やファイルのデータがあると説明しましたが、送信する情報としてはそれだけではありません。ファイル以外の情報も一緒に送信されます。また複数ファイルを一度に送信することもあり、それらが混ざってしまうとわからなくなるためファイルを分けておくことが必要となります。

つまりファイルとその他の情報を分けるためと、ファイル同士を分けるために、この「境界線」が必要になります。その役割を持っているのが「boundary」になります。「boundary」に設定された文字列が境界線のキーとなって区分けを行います。

「boundary」として設定できる文字列は特に決まった文字列があるわけではありません。送信側が任意で設定することができます。半角英数字といくつかの特殊記号「'()+_,-./:=?」から70文字以内の文字列であれば構いません。今回は「Boundary01」としてみました。

ちょっとイメージがつきにくいですかね。わかりやすく書いてみたいと思います。

--Boundary01
Content-Disposition: form-data; name="file"; filename="dogs.png"
Content-Type: image/png

[ファイルのデータ]
--Boundary01--

いかがでしょうか?
「–Boundary01」で上下で挟んでいますが、この上下で宣言している箇所がある意味境界線となっており、他の情報と区別しているわけです。

境界線に挟まれた間にあるものがファイルのデータになっており、Content-Dispositionにはファイル名(filename=”dogs.png”)を、ファイルのContent-Typeにはファイルの種類(image/png)を指定します。あとはファイルのデータを入れれば、これで1ファイルのデータとしてまとめることができます。

では、次はその中のファイルのデータの話になります。
こちらはファイルをバイナリデータにして送信します。ただし、バイナリデータをそのまま送信するのではなく、Blobというデータにして送信するようにします。これは任意のバイナリデータを1つのデータ型としてまとめるためです。

早速それらをまとめました。以下はその際のコードになります。

let data = "";
data += "--Boundary01\r\n";
data += "Content-Disposition: form-data; name=\"file\"; filename=\"" + attachment.getName() + "\"\r\n";
data += "Content-Type:" + attachment.getContentType() + "\r\n\r\n";

let payload = Utilities.newBlob(data).getBytes()
    .concat(attachment.copyBlob().getBytes())
    .concat(Utilities.newBlob("\r\n--Boundary01--").getBytes());
  • ファイル名やファイルの種類、ファイルのデータは Gmail の attachments から取得できます。Gmail 以外でも同じように必要な項目だけセットすれば使えるので流用していただければと思います。

順に説明すると、まずは境界線である「boundary」や、ファイル名、ファイルの種類を1つにまとめます。次にファイルを Blob型の形式にしたものを取得し、最初にまとめたものとくっつけます。最後に「boundary」で蓋をしたら完成です(最後のboundaryの最後尾には「–」が必要となります)。
( Blobに変換はしているのですが、そのままだと送信できないので更に Byte にしてます)

これでファイルの準備は終わりになります。

ちょっと複雑に感じるかもしれません。
ただ、行なっていることとしては以下の部分をただ Blob型に変換しているだけなんです。そう考えるとちょっとスッキリしませんでしょうか?

--Boundary01
Content-Disposition: form-data; name="file"; filename="dogs.png"
Content-Type: image/png

[ファイルのデータ]
--Boundary01--

では、ファイルの準備ができたので、いよいよ Backlog にアップロードしていきます。

Backlog にファイルをアップロードしてみよう

アップロードはこちらの 添付ファイルの送信する API を使います。

let options = {
  'contentType' : 'multipart/form-data; boundary=Boundary01,
  'method' : 'post',
  'payload' : payload
};

UrlFetchApp.fetch("https://<スペースID>.backlog.com/api/v2/space/attachment?apiKey=<あなたのAPIキー>", options);
  • payload は「ファイルを準備しよう」でまとめたファイルデータになります。
  • <スペースID> にはご利用のスペースのスペースIDに置き換えてください。詳しくはスペースIDとはのヘルプを参考にしてください。
  • <あなたのAPIキー> はご利用のアカウントの API に置き換えてください。詳しくは APIキーのヘルプを参考にしてください。

前回の課題の追加に比べると contentType にファイル送信用のコンテキストと、boundary の文字列を指定するように変わり、UrlFetchApp で送信する URL がファイル送信用の URL に変わっただけとなります。
ファイルの準備に比べると、そこまで複雑ではないですね。

最後に、ファイルを送信すると ID が発行され、ID が送信元に返ってくると説明していましたが、後はその ID を課題の追加の時のパラメーターとして渡せば、課題の追加と一緒にファイルが添付されるようになります。

ファイルのアップロードと課題の追加の処理を1つにしよう

では、早速、ファイルのアップロードと課題の追加の処理をまとめていきましょう(課題の追加処理は前回のブログから持ってきています)。
まとめたものが以下になります。

function myFunction() {
  let query = "label:new-inquiry-mail";
  let threads = GmailApp.search(query);

  for (let th in threads) {
    let thread = threads[th];
    let mails = thread.getMessages();

    if (thread.getMessageCount() === 1) {
      for (let ml in mails) {
        let mail = mails[ml];

        let subject = mail.getSubject();
        let body = mail.getPlainBody();
        let attachments = mail.getAttachments();

        let attachmentsParams = '';
        attachmentsLoop:
        for (var attach in attachments) {
          let attachment = attachments[attach];
          let attachmentId = uploadAttachment(attachment);

          if (attachmentId > 0) {
            attachmentsParams += "&attachmentId[]=" + attachmentId;

          } else if (attachmentId == -1) {
            body += "\r\n&color(#f00) {10MB以上のファイルが添付されていました。メールの添付ファイルから直接取得をお願いいたします。}";

          } else if (attachmentId == -2) {
            break attachmentsLoop;

          }
        }

        let parameters = {
          'projectId' : '34521',
          'issueTypeId' : '7231',
          'priorityId' : '3',
          'summary' : subject,
          'description' : body
        }

        let options = {
          'method' : 'post',
          'contentType' : 'application/x-www-form-urlencoded',
          'payload' : parameters,
          'muteHttpExceptions' : true
        }

        let res = UrlFetchApp.fetch("https://<スペースID>.backlog.com/api/v2/issues?apiKey=<あなたのAPIキー>" + attachmentsParams, options);
        Logger.log(res.getResponseCode());
        if (res.getResponseCode() < 200 || res.getResponseCode() > 299) {
          Logger.log(res.getContentText());
          break;
        }
        removeLabel(thread);
      }
    } else {
      removeLabel(thread);
    }
  }
}

function uploadAttachment(attachment) {
  let size = attachment.getSize();
  if (size > 10485760) {
    return -1;
  }

  let data = "";
  data += "--Boundary01\r\n";
  data += "Content-Disposition: form-data; name=\"file\"; filename=\"" + attachment.getName() + "\"\r\n";
  data += "Content-Type:" + attachment.getContentType() + "\r\n\r\n";
  
  let payload = Utilities.newBlob(data).getBytes()
    .concat(attachment.copyBlob().getBytes())
    .concat(Utilities.newBlob("\r\n--Boundary01--").getBytes());

  let options = {
    'contentType' : 'multipart/form-data; boundary=Boundary01',
    'method' : 'post',
    'payload' : payload
  };

  let res = UrlFetchApp.fetch("https://<スペースID>/api/v2/space/attachment?apiKey=<あなたのAPIキー>", options);
  if (res.getResponseCode() < 200 && res.getResponseCode() > 299) {
    return -2;
  }
  return JSON.parse(res.getContentText())['id'];
}

function removeLabel(thread) {
  let label = GmailApp.getUserLabelByName("new inquiry mail");
  thread.removeLabel(label);
}

ファイルのアップロードの部分に少しだけ例外処理を追加しています。課題に添付できるファイルサイズの上限があるので、サイズのチェックを行う箇所を追加しました。またファイルのアップロードに失敗した場合も考慮し、同様に例外処理を追加しています。

これでプログラミングは終了になります。

今回はファイルのデータを作る部分が少し複雑でしたね。
あまり使わないけど、いざプログラミングすると難しかったり、boundaryの部分などは形式がある分、そこから少し外れると失敗することもあり、ハマって何度もテストを行なってしまったりすることが多いです。

今後、ファイルのアップロードを検討していたり、現在作成されている方の参考になれば幸いです。

さいごに

いかがでしたでしょうか?
やりたいことの幅が少し増えましたか?

このようにパズルのように組み立ていくことで自分の API を作ることも可能です。まだ API を使ったことないという方も、試しにチャレンジしてみてくださいね。

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる