sironekotoroの日記

Perl で楽をしたい

Perl で Google Drive API を使ってファイルをアップロードする

Google Drive API を使ってファイルをアップロードしたい!

これが超苦労しましたん・・・

先に blog で書いたとおり、自分の環境(と技能)では、Google Drive を操作するモジュールを使うことができませんでした。

しかし!

Google Drive APIREST API なので、特別モジュールを使わずともいけるはずです。

stackoverflow.com

We have a REST API. You can use any language to implement your own client, you don't have to use one of the client libraries we support.

(DeepLで翻訳)私たちはREST APIを持っています。独自のクライアントを実装するために任意の言語を使用することができます。

実際、一つ前のエントリに書いたファイルリストの取得であれば、https://www.googleapis.com/drive/v3/files?access_token=アクセストークン をブラウザの URL 欄に貼り付けてアクセスすれば表示が可能です。

f:id:sironekotoro:20201107160301p:plain

ってことで、API を生で扱うツールとして curl に目をつけ、利用事例を探しました。そこから Perl の http クライアントに実装していこうという狙いです。

curl を使って Google Drive にファイルをアップロードする

curl というのは、macoslinux 、そして Windows10 にも入っている HTTP クライアントです。

Windows10 でも使えるのはついさっき知りました・・・

ascii.jp

ターミナルやコマンドプロンプトから curl https://www.yahoo.co.jp とかやると応答が返ってくるはずです。

curl での実装を探して見つけたサイトが以下です。割と自然な日本語のタイトルなんですが、日本語に自動翻訳されたサイトみたいです。

www.366service.com

ここのサンプルを用いて Google Drive の任意のフォルダにファイルをアップロードすることができました。

curl -X POST \
 -H "Authorization: Bearer アクセストークン" \
 -F "metadata={ \
              name : 'hoge.txt', \
              mimeType : 'plain/text', \
              parents: ['1PSb3xH000llDtXSWLaAtKEXJy5DY_Wic'] \
              };type=application/json;charset=UTF-8" \
 -F "file=@hoge.txt;type=plain/text" \
 "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"

ここでハマったのは、parents: ['1PSb3xH000llDtXSWLaAtKEXJy5DY_Wic'] \[ ] は削除するものと思い、結果、いつまでも Google Drive の指定したフォルダの中に入らずに困ったという。

いや、先のサンプルで -H "Authorization: Bearer [ACCESS-TOKEN]" \ とあるんだけど、ここは実際には [ ] なしで入力するので、parents のところも [ ] いらないと思ったんですよね・・・

結局、公式のリファレンスでここにはリストを渡す、って記載を発見してやっと気づいたという。公式はいつも大事(1回目)

developers.google.com

parents[] list

Perl を使って Google Drive にファイルをアップロードする

curl でできるなら、Perl でもできる!あとは移植していくだけや!と意気揚々といつもの use HTTP::Tiny をタイプしますが、ここで手が止まります。

Perl を使ってファイルアップロード?

HTTP::Tiny でファイルのアップロードどうやるんだ?

GET, POST 共に使ったことありますが、はて、ファイルアップロードはしたことない・・・

ググります。

stackoverflow.com

Not easily. HTTP::Tiny contains no support for the multipart/form-data content type required by file uploads. (That's one of the reasons it's called "Tiny".)

(DeepLで翻訳)簡単にはできません。HTTP::Tiny は、ファイルのアップロードに必要な multipart/form-data コンテンツタイプをサポートしていません。(これが "Tiny" と呼ばれる理由の 1 つです)。

いや、curl でアクセスするときに multipart とか書いてあるんやけど、それ無いのん・・・この時点で心が折れかかるんですが、大丈夫、我々には Furl, LWP::Simple, LWP::UserAgent がある!

そして、どうやら POST するときは HTTP::Request::Common でリクエストメッセージを作るのが良い、という事を知ります。

qz.tsugumi.org

ただし "multipart/form-data" を送る場合は、post() メソッドを使ってリクエストを行うよりも、HTTP::Request::Common オブジェクトを直接扱って LWP::UserAgent へ request() させた方がいいかも知れない。boundary だのファイル名だの若干複雑なので……。

これは多分、先の Stack overflow で回答者が最後に書いていた

Correctly populating $multipart_form_data is left as an exercise for the reader. :-)

(DeepLで翻訳)multipart_form_dataを正しく入力することは、読者のための練習として残されています。)

ここを解決するのが、この HTTP::Request::Common なのかなぁ?と思っています。

煮詰まって助けてもらう

なんとか、LWP::UserAgent での実装にたどり着いたものの、動かない。

煮詰まったので、 Perl入学式の公開チャンネルに質問したのでした。

Slack

そこに上げた、動かないコードがこちら。

use HTTP::Request::Common;
use LWP::UserAgent;
my $ua  = LWP::UserAgent->new;
my $res = $ua->request(
    POST
        'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
    Authorization =>
        'Bearer アクセストークン',
    Content_Type => 'multipart/form-data',
    Content      => [
        metadata => encode_json(
            {   name     => 'hoge.txt',
                mimeType => 'plain/text',
                parents  => ['1PSb3xH000llDtXSWLaAtKEXJy5DY_Wic'],
            }
        ),
        file => ["./hoge.txt"],
    ]
);
say $res->code;  # 400
say $res->content;
# {
#  "error": {
#   "errors": [
#    {
#     "domain": "global",
#     "reason": "badContent",
#     "message": "Unsupported content with type: application/octet-stream"
#    }
#   ],
#   "code": 400,
#   "message": "Unsupported content with type: application/octet-stream"
#  }
# }

ベテランの Perl Monger 達に助けてもらいました。本当にありがとうございます。

id:anatofuz , id:karupanerura, id:shoichikaji

動かなかった原因

これは curl と LWP::UserAgent の Dump をとって分かったのですが、metadata 部のリクエストがうまく組み立てられていませんでした。あと typo も。

ちなみに、LWP の Dumper はこんな感じで入れられます。(slack で教えてもらった)

metacpan.org

$ua->add_handler("request_send",  sub { shift->dump; return });
$ua->add_handler("response_done", sub { shift->dump; return });

以下は LWP::UserAgent の Dump の抜粋です。

--xYzZY\r
Content-Disposition: form-data; name="metadata"\r
\r
{"mimeType":"plain/text","name":"hoge.txt","parents":["10kCqEUmWsWlqMdP_vF9pDGrQXFVZ-Lvr"]}\r
--xYzZY\r
Content-Disposition: form-data; name="file"; filename="plain/text"\r
Content-Type: text/plain\r
\r
hoge
\r

metadata のところに Content-Type: がありません。json のデータを送る旨明示しなければならないのですが、それがありません。

対して、file のところには Content-Type: text/plain\r があります。

つまり、metadata のところのデータ構造の構築に失敗していました。

で、どうやってこの metadata のところに curl と同じ application/json;charset=UTF-8 を入れるんだ?となりました。

どう書けば良いかはちゃんと HTTP::Request::Common 公式にありました。公式大事(2回目)

perldoc.jp

複数の値を持つフォームフィールドは、フィールド名を繰り返すか、 配列リファレンスを渡すことで指定できます。

POST メソッドはRFC1867 で示された Form-based File Upload のために使われる multipart/form-data コンテントもサポートします。 リクエストヘッダの一つとして 'form-data' のコンテントタイプを 指定することにより、このコンテントフォーマットを利用することが出来ます。 もし $form_ref の中の値の1つが配列リファレンスであれば、それは以下の解釈で ファイル部分の指定であるように扱われます:

[ $file, $filename, Header => Value... ]

[ undef, $filename, Header => Value,..., Content => $content ]

配列での先頭の値 ($file) はオープンするファイルの名前です。 このファイルは読みこまれ、その内容がリクエストに入れられます。 もしファイルをオープンできなければルーチンは croak します。 コンテントを直接 Content ヘッダで指定したければ $file の値を undef に してください。 $filename はリクエストで報告されるファイル名です。 この値が未定義であれば、$file の基本名が使われます。 $file の値を提供したとき、ファイル名の送信をよくせいしたいなら、 $filename に空文字列を指定することができます。

なるほど。

動かないコードだと metadata に対する値は一つだけでした。

[ $file, $filename, Header => Value... ]$file しか入っていない状態です。

しかも、モジュールは file が来ることを想定してるのに、渡しているのは(JSON化した)文字列です。なるほど動かん。

        metadata => encode_json(
            {   name     => 'hoge.txt',
                mimeType => 'plain/text',
                parents  => ['1PSb3xH000llDtXSWLaAtKEXJy5DY_Wic'],
            }

これを直すとこうです。

  • [ undef, $filename, Header => Value,..., Content => $content ] の形式にする

  • コンテントを直接 Content ヘッダで指定したければ $file の値を undef に してください に従う

  • JSONの文字列は Content キーの値に移動

適用した配列リファレンスを metadata の値に渡しています。

        metadata => [
            undef, # ファイル使わないので undef
            undef, # ファイルからデータを取得しないので undef
            'Content-Type' => 'application/json;charset=UTF-8',
            'Content' => encode_json( # jsonのデータは Content キーの下に
                {   name     => 'hoge.txt',
                    mimeType => 'plain/text',
                    parents  => ['10kCqEUmWsWlqMdP_vF9pDGrQXFVZ-Lvr'],
                },
            ),
        ],

PerlGoogle Drive API を使ってファイルをアップロードする(タイトル回収)

ということで、やっと PerlGoogle Drive にファイルをあげることができました。

#!/usr/bin/env perl
use strict;
use warnings;

binmode STDOUT, ":utf8";

use HTTP::Request::Common;
use JSON qw/encode_json/;
use LWP::UserAgent;

my $GOOGLE_DRIVE_UPLOAD_API
    = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";

my $ua = LWP::UserAgent->new;
my $res = $ua->request(
    POST $GOOGLE_DRIVE_UPLOAD_API,
    'Content-Type' => 'multipart/form-data',
    Authorization =>
        'Bearer アクセストークン',
    Content => [

        metadata => [
            undef,
            undef,
            'Content-Type' => 'application/json;charset=UTF-8',
            'Content' => encode_json(
                {   name     => 'hoge.txt',
                    mimeType => 'plain/text',
                    parents  => ['10kCqEUmWsWlqMdP_vF9pDGrQXFVZ-Lvr'],
                },
            ),
        ],

        file => ["./hoge.txt"],
    ],
);

print  $res->code . "\n";
print  $res->content . "\n";

# 200
# {
#  "kind": "drive#file",
#  "id": "19f2RrocH4I3Mdig0LkmNPghJDZnmq35f",
#  "name": "hoge.txt",
#  "mimeType": "plain/text"
# }

おまけ HTTP::Tiny::Multipart

むやみやたらとググる中、コアモジュール民(自分)が大好きな HTTP::Tiny の拡張がありました。HTTP::Tiny::Multipart です。

metacpan.org

これを使って Google Drive へのアップロードを書いてみました。こちらも動きました。

#!/usr/bin/env perl
use strict;
use warnings;

binmode STDOUT, ":utf8";

use File::Slurp qw/read_file/;
use HTTP::Tiny;
use HTTP::Tiny::Multipart;
use JSON;

my $GOOGLE_DRIVE_UPLOAD_API
    = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";
my $ACCESS_TOKEN
    = "";
my $bearer = join " ", ( 'Bearer', $ACCESS_TOKEN );

my $content = read_file('./hoge.txt');

my $http = HTTP::Tiny->new( default_headers => { Authorization => $bearer } );

my $response = $http->post_multipart(
    $GOOGLE_DRIVE_UPLOAD_API,
    {   metadata => {
            content => encode_json(
                {   name     => 'hoge.txt',
                    mimeType => 'plain/text',
                    parents  => ['10kCqEUmWsWlqMdP_vF9pDGrQXFVZ-Lvr'],
                },
            ),
            content_type => 'application/json; charset=utf-8',
        },

        file => {
            filename     => './hoge.txt',
            content      => $content,
            content_type => 'plain/text',
        },
    }
);
print $response->{content} . "\n";

# {
#  "kind": "drive#file",
#  "id": "1UInkENiv0GApyfhJWyxgNaplw512Mmw2",
#  "name": "hoge.txt",
#  "mimeType": "plain/text"
# }

後始末

f:id:sironekotoro:20201108104903p:plain