Server-Sent Events を使ってみる

Server-Sent Events とは

JavaScript の EventSource インターフェースから使える、 HTTP 上で Push Notification を実現するためのもの。近々 CORS サポートも標準仕様になるらしい。サポート状況は PC だと IE だけ使えないというお決まりパターンで、モバイルだと iOS Safari のみ対応。 (参考:http://caniuse.com/eventsource)

コネクションは接続しっぱなしなので、都度リクエストを飛ばす Comet よりも効率的で MXHR 寄り。ライブラリ使わずに済むので全ブラウザで EventSource がサポートされれば相当嬉しい機能。接続のリトライ等はブラウザ側で扱ってくれたりするので地味に使い勝手が良さそう。

実装

適当なフレームワークといえば Tatsumaki だろうということで、 Server-Sent Events を追加して pull request 出しておいた

クライアント側で書くべきはこれだけなのでなかなか使いやすい。

var e = new EventSource("/chat/foo/sse");
e.addEventListener("message", function (payload) {
    var data = JSON.parse(payload.data)
    onNewEvent(data);
});

ブランチを checkout して http://0.0.0.0:5000/chat/foo?sse=1 とかでアクセスすれば Server-Sent Events を使ったチャットが使える。 Chrome だと response に何も出なくて何が起きているか全然わからないので、途中 console.log() を挟みつつ挙動を見てください。

サーバサイド

GET に対して Content-Type を text/event-stream でレスポンスして、その後は \n\n 区切りの行ベースでイベントを返してやれば良い。

GET /chat/foo/sse HTTP/1.1

HTTP/1.0 200 OK
Content-Type: text/event-stream

event: message
data: {"message": "Hi", "username": "foo"}

event: message
data: {"message": "Hi", "username": "bar"}

フィールドは event, data, id, retry が指定できる。 data フィールドは UTF-8 のテキストであれば良いので JSON 以外も使える。 コロン (:) から始まる行はコメントになる。

クライアントサイド

EventSource インスタンス生成して event を指定して event listener を追加してやると、ブラウザ側でイベント名に応じて event listener に送られる。 message event を処理するなら次のように書けばよい。

var e = new EventSource("/chat/foo/sse");
e.addEventListener("message", function (payload) {
    var data = JSON.parse(payload.data)
    onNewEvent(data);
});

参考リンク


定数を定義するのを楽にする Constant::Exporter を書いた

クラス特有の定数であれば use constant してあげれば良いだけですが、 (小さめの)アプリで共通で使う定数を定義する場合は手間で、だいたい次のような作業が必要に。

  • use constant または サブルーチン定義
  • Exporter の @EXPORT やら @EXPORT_OK やら @EXPORT_TAGS やらを書く

この書き方では定数クラス自体の見通しも悪くなる上この作業が面倒で、 数人で作っている規模のアプリであれば定数はまとまっていたほうが見通し良いので、 Exporter の機能全部使える Constant::Exporter を書いてみた次第。

定数使うにしても全部 import されるのも嫌なので、タグをうまく使ってあげると追いやすいコードになるかな、と思ってます。

使い方は見ての通りで、

package MyApp::Constants;
use strict;
use warnings;

use Constant::Exporter (
    EXPORT => {
        FB_CLIENT_ID => 12345,
    },
    EXPORT_OK => {
        TITLE_MAX_LENGTH => 128,
    },
    EXPORT_TAGS => {
        user_status => {
            USER_STATUS_FB_ASSOCIATED     => 1,
            USER_STATUS_FB_NOT_ASSOCIATED => 0,
        },
    },
    EXPORT_OK_TAGS => {
        fb_api_error => {
            ERROR_OAUTH       => 190,
            ERROR_API_SESSION => 102,
            ERROR_API_USER_TOO_MANY_CALLS => 17,
        },
        fb_payment_error => {
            ERROR_PAYMENTS_ASSOCIATION_FAILURE   => 1176,
            ERROR_PAYMENTS_INSIDE_IOS_APP        => 1177,
            ERROR_PAYMENTS_NOT_ENABLED_ON_MOBILE => 1178,
        },
    },
);

1;

と書いておくと use MyApp::Constants qw( TITLE_MAX_LENGTH :fb_api_error ); と書けば定数を絞って使えますし、 use MyApp::Constants; すれば EXPORTEXPORT_TAGS に書いた定数が import されるので、利用頻度の高いものを書いておくと幸せになれる雰囲気がします。

https://metacpan.org/module/Constant::Exporter


YAPC::Asia Tokyo 2013 でモダン Perl Tips 50 選についてトークします

今年も YAPC::Asia Tokyo 2013 が開催されます。

トークを採択していただいたので、「モダン Perl Tips 50 選 」というタイトルで発表する予定です。

プロポーザルにも書いてありますが、最近こんな感じで開発してるよーっていう内容を話す予定です。結構盛り盛りな内容になっていて、20分で50個は無理という噂もありますが…。

リクエスト受けてからレスポンスを返すまで、ひと通り必要そうなモジュールとちょっと踏み込んだ使い方を提示出来ればいいかなーと思っています。

チケットも8/11まで絶賛発売中 なのでまだ購入していないかたはお早めに。

去年一昨年からすると運用寄りのトークの割合が減って、開発環境とかテストとかのトークが増えた印象です。トレンドに即しているのでしょう。あとは、実務から離れたマニアックなトークも結構あるので期待しています。当日が楽しみですね。

参考: YAPC::Asia Tokyo 2013でカジュアルなデータベース関連開発についてトークします | おそらくはそれさえも平凡な日々


master を stable にする話

github + pull request の cons

github を普段から使っていると自然と pull request ベースで進める事になる(この仕組みはだいぶうまくできていると思う)が、それでも厄介事は尽きない。

一日数件のペースで pull request が送られるような絶賛開発中のリポジトリでは頻繁に master にマージされるが、Web UI 経由で簡単に git merge foo できるため、 マージ後の master でテストを走らせるような機会がそれほどない、というかそもそも面倒でやらない(本当はみんなテストの重要性をわかってはいるんだけど…)

unstable master

すると、いつの間にか master が unstable になっていて困った事になる。壊れたテスト(あるいはコードベース)のせいで自分の書いたコードが期待通りの動作をしているか保証できなくなる。

せめて master だけでも stable にしたい。そういう時に限ってテストを走らせるサーバを準備している余裕は無い。

done is better than perfect

完璧を目指すなら、 Jenkins で云々設定をして走らせることになると思う 。(Jenkins が完璧かどうかは知らないが…)

サーバが準備できないからといって不便な状況を許容するよりは、荒削りでも動くもののほうが有効なようだ。今回は ikachan + prove + α をローカルで走らせるだけで事足りた。 短時間で準備できて思いのほかワークする単純な仕組みとして費用対効果が大きい。

prove の出力するテストのサマリーを結果を流す IRC チャンネルを作って、みんなに入ってもらうだけで済んだ。

master に変更が入った時だけ走らせれば良いので、その辺りは記事の最後にあるスクリプトでコントロールしている。

PASS するとこれだけ。

12:39 ikachan: Start prove...
12:41 ikachan: All tests successful.
12:41 ikachan: Files=168, Tests=1355, 131 wallclock secs ( 0.85 usr  0.35 sys + 64.24 cusr  7.08 csys = 72.52 CPU)
12:41 ikachan: Result: PASS

FAIL するとこんな感じで教えてくれる。これで周知されるので、 merge した人が修正してくれる。

12:19 ikachan: Start prove...
12:22 ikachan: Test Summary Report
12:22 ikachan: -------------------
12:22 ikachan: t/foo/bar/baz.t                                                                (Wstat: 256 Tests: 14 Failed: 2)
12:22 ikachan:  Failed tests:  1-2
12:22 ikachan:  Non-zero exit status: 1
12:22 ikachan: Files=168, Tests=1355, 127 wallclock secs ( 0.89 usr  0.34 sys + 63.37 cusr  7.00 csys = 71.60 CPU)
12:22 ikachan: Result: FAIL

メンテナンスの段階になったらどこかのサーバに置いておくのが良さそうだ。

package App::provechan;
use sane;
use LWP::UserAgent;

sub new {
    my ($class, %args) = @_;
    bless {
        channel     => $args{channel},
        force_prove => $args{force_prove} // 0,
        sleep_sec   => $args{sleep_sec}   // 60,
        prove       => $args{prove}       // [ qw/ prove -r t / ],
        useragent   => LWP::UserAgent->new,
        skip_regexp => quotemeta "Already up-to-date.",
    }, $class;
}

sub run {
    my $self = shift;

    while (1) {
        if ($self->should_skip) {
            $self->log("Skip");
            if ($self->{force_prove}) {
                $self->{force_prove} = 0;
            } else {
                sleep $self->{sleep_sec};
                next;
            }
        }

        $self->installdeps;

        $self->prove;
    }
}

sub prove {
    my $self = shift;

    $self->send(privmsg =>"Start prove...");

    open my $fh, '-|', @{ $self->{prove} }
        or die "failed open pipe: $!";

    my @ret;
    while (<$fh>) {
        push @ret, $_;
        $self->log($_);
    }

    my @messages;
    for (my $i = 0; $i < @ret; $i++) {
        my $line = $ret[$i] // '';
        if ($line  =~ /Test Summary Report/) {
            push @messages, @ret[ $i .. $#ret ];
            last;
        } elsif ($line =~ /All tests successful/) {
            push @messages, @ret[ $i .. $#ret ];
            last;
        }
    }

    $self->send(notice => $_) for @messages;
}

sub should_skip {
    my $self = shift;

    my $pull = `git pull`;
    $self->log($pull);

    if ($pull =~ /$self->{skip_regexp}/m) {
        return 1;
    }
}

sub installdeps {
    my $self = shift;

    open my $installdeps, '-|', qw! cpanm --installdeps . !
        or die "failed open pipe: $!";

    $self->log($_) while (<$installdeps>);
}

sub log {
    my $self = shift;
    my $log  = shift // '';
    chomp $log;
    printf "[%s] %s\n", scalar localtime, $log;
}

sub send {
    my ($self, $method, $message) = @_;
    $self->{useragent}->post(
        "http://localhost:4979/$method",
        Content => {
            channel => $self->{channel},
            message => $message,
        }
    );
}

package main;

my $opts = {
    channel     => '#nantoka-ci',
    prove       => [ qw! env LOCAL_MYSQLD_SOCK=/tmp/mysql_sandbox5163.sock prove -r t/ ! ],
    force_prove => shift @ARGV,
};

App::provechan->new(%$opts)->run;

__END__

Esc/C-[ のついでに IME もオフにして快適 vim ライフ

KeyRemap4MacBook の private.xml を編集すると、iTerm2 での操作時にフックして日本語入力の ON/OFF を切り替えられる。 これを利用して vim の insert mode から抜けるついでに日本語入力も OFF にしてしまえば、:え だとか っっっj といった悲しい思いをしなくて済む。

設定は2つで、ともにシステム環境設定から行える。

KeyRemap4MacBook

Misc & Uninstall タブの Open private.xml を選択して、 private.xml を次のように編集する。

private.xml

<?xml version="1.0"?>
<root>
    <list>
        <item>
            <name>Leave Insert Mode with Esc(Terminal)</name>
            <identifier>private.turn_ime_off_by_esc</identifier>
            <only>TERMINAL</only>
            <autogen>--KeyToKey-- KeyCode::ESCAPE, KeyCode::ESCAPE, KeyCode::JIS_EISUU</autogen>
            <autogen>--KeyToKey-- KeyCode::BRACKET_LEFT, VK_CONTROL, KeyCode::BRACKET_LEFT, VK_CONTROL, KeyCode::JIS_EISUU</autogen>
        </item>
    </list>
</root>

言語とテキスト

次に iTerm2 の特定のペインだけで日本語入力が切り替わるように設定する。

入力ソース タブの 入力ソースのオプション書類ごとに異なるものを使用 にチェックを入れる

以上で設定は完了。 vim で EscC-[ で insert mode を抜けると同時に日本語入力がOFFになる。


ブログ移行した

制約のない砂場が欲しかったので移行しました。


モジュールのバージョンを表示する

ちょっと前に盛り上がったような気もするけれども、そんな凝ったことはしないで bashrc に pmver を書いておくだけでいいかなぁ

function pmver () { perl -M$1 -le "print \$$1::VERSION"; }

さくらVPS移行メモ

メモリが 512MB から 2GB に。ヤッター。OS は Debian 6 amd64 をさくっと入れておく。

他にもやっておいた方がよさそうなものがあったら教えて下さい。

ssh root@ip

とりあえず foo で sudo 出来るように。

# apt-get update
# apt-get upgrade
# dpkg-reconfigure tzdata
# apt-get install sudo
# vi /etc/sudoers
# diff -u /tmp/sudoers /etc/sudoers
--- /tmp/sudoers	2012-05-04 05:20:48.000000000 +0900
+++ /etc/sudoers	2012-05-04 05:21:05.000000000 +0900
@@ -15,6 +15,7 @@
 
 # User privilege specification
 root	ALL=(ALL) ALL
+foo	ALL=(ALL) ALL
 
 # Allow members of group sudo to execute any command
 # (Note that later entries override this, so you might need to move
# exit

ssh foo@ip

公開鍵認証

$ mkdir .ssh
$ vi .ssh/authorized_keys
$ chown -R foo:foo .ssh
$ chmod 700 .ssh
$ chmod 600 .ssh/authorized_keys
$ exit

デフォルトのポートはログが悲惨なことになるので適当に。rootとパスワード認証は禁止する

$ sudo vi /etc/ssh/sshd_config 
$ diff -u /tmp/sshd_config /etc/ssh/sshd_config 
--- /tmp/sshd_config	2012-05-04 05:31:05.000000000 +0900
+++ /etc/ssh/sshd_config	2012-05-04 05:35:15.000000000 +0900
@@ -2,7 +2,7 @@
 # See the sshd_config(5) manpage for details
 
 # What ports, IPs and protocols we listen for
-Port 22
+Port 2000 # ここは適当に
 # Use these options to restrict which interfaces/protocols sshd will bind to
 #ListenAddress ::
 #ListenAddress 0.0.0.0
@@ -47,7 +47,9 @@
 ChallengeResponseAuthentication no
 
 # Change to no to disable tunnelled clear text passwords
-#PasswordAuthentication yes
+PasswordAuthentication no
+
+PermitRootLogin no
 
 # Kerberos options
 #KerberosAuthentication no
$ sudo service ssh restart

とりあえず http, https, ssh だけ開けておく。

$ sudo apt-get install arno-iptables-firewall

※ここで別のターミナルを開いて ssh foo@ip -p 2000 で締め出されていないか確認。

logwatch で定期的にメールでレポートを送る。

$ sudo apt-get install postfix logwatch
$ sudo vi /usr/share/logwatch/default.conf/logwatch.conf
$ diff -u /tmp/logwatch.conf /usr/share/logwatch/default.conf/logwatch.conf
--- /tmp/logwatch.conf	2012-05-04 06:35:12.000000000 +0900
+++ /usr/share/logwatch/default.conf/logwatch.conf	2012-05-04 06:37:50.000000000 +0900
@@ -32,7 +32,7 @@
 #Output/Format Options
 #By default Logwatch will print to stdout in text with no encoding.
 #To make email Default set Output = mail to save to file set Output = file
-Output = stdout
+Output = mail
 #To make Html the default formatting Format = html
 Format = text
 #To make Base64 [aka uuencode] Encode = base64
@@ -41,7 +41,7 @@
 # Default person to mail reports to.  Can be a local account or a
 # complete email address.  Variable Output should be set to mail, or
 # --output mail should be passed on command line to enable mail feature.
-MailTo = root
+MailTo = user@example.com
 # WHen using option --multiemail, it is possible to specify a different
 # email recipient per host processed.  For example, to send the report
 # for hostname host1 to user@example.com, use:
@@ -67,7 +67,7 @@
 
 # The default time range for the report...
 # The current choices are All, Today, Yesterday
-Range = yesterday
+Range = Today
 
 # The default detail level for the report.
 # This can either be Low, Med, High or a number.
$ sudo EDITOR=vi crontab -e
0 1  * * *          /usr/sbin/logwatch

気軽にグラフを見て監視したいので munin で、1台だけなので設定はこれだけ。

$ sudo apt-get install munin-node munin
$ sudo vi /etc/munin/munin.conf 
$ diff -u /tmp/munin.conf /etc/munin/munin.conf 
--- /tmp/munin.conf	2012-05-04 06:54:10.000000000 +0900
+++ /etc/munin/munin.conf	2012-05-04 06:55:34.000000000 +0900
@@ -5,10 +5,10 @@
 # must be writable by the user running munin-cron.  They are all
 # defaulted to the values you see here.
 #
-# dbdir	/var/lib/munin
-# htmldir /var/cache/munin/www
-# logdir /var/log/munin
-# rundir  /var/run/munin
+dbdir	/var/lib/munin
+htmldir /var/cache/munin/www
+logdir /var/log/munin
+rundir  /var/run/munin
 #
 # Where to look for the HTML templates
 # tmpldir	/etc/munin/templates
$ sudo /etc/init.d/munin-node restart

便利ツールを一式入れておく

$ sudo apt-get build-dep perl
$ sudo apt-get install \
    build-essential \
    ssh \
    htop \
    vim \
    git-core \
    screen \
    unzip \
    global \
    ctags \
    curl \
    spell \
    strace \
    sysstat \
    tree \
    libpcre3-dev \
    libssl-dev \
    expat \
    libexpat1-dev \
    libxml2-dev \
    libjpeg8-dev \
    libgif-dev \
    libpng12-dev \
    daemontools-run

あとは必要な httpd, memcached, mysql 等を好きなように。


疎結合と MVC と JSON API とオーバーヘッドと非同期とレスポンスタイムに関する適当な考察

最近考えていることを話す機会があったので文章にしてまとめてみる。

疎結合

昨今の複雑化するウェブアプリケーションを効率的に開発するにあたって、疎結合な設計にすることは開発/保守効率を上げるためには必須の条件となることは経験上嫌というほど皆が経験している。(このへんの感覚がわからない人は一度疎結合なアプリケーションを書いてみると良い)

疎結合な設計をすることで問題の切り分けが容易になり、自動化されたユニットテスト・コンポーネントテスト・ UAT が手元の MacBook で実行でき、高いカバレッジに助けられて臆すること無くコードに手を入れられる環境を入手でき、開発する上でストレスなく障害も少ないというメリットが享受できる。

話を戻して、疎結合な設計の例をみると、ウェブサーバとアプリケーションを分離することであるとか、 MVC であるとか、良質かつ単体で動く小さなモジュールを組み合わせて書くといった例があげられ、また最近ではWebサービスのAPIを組み合わせるマッシュアップも台頭し、これも一種の疎結合な設計に含めて考えても良い。この概念に近いものとして MVC にサービス層を含めるような設計をすることでモデル層を抽象化し、コンポーネントテストを書きやすくする設計もチラホラ見かける。

MVC と JSON API

「MVC + サービス層」の設計でアプリケーションを書くほど抽象化するのであれば、それはWebサービスのAPIを提供することとやっていることの本質はさほど変わらないのではないかと思う。そこでいっそのこと振り切って考えて、サービス層に当たる部分に JSON を返せるだけの薄い Controller をのせ、 HTTP サーバとして投入することで設計をより一層疎結合なものへと変更できる。

こうすることでブラウザからリクエストを受けるアプリサーバの Controller は、認証等が済んだ状態で JSON API を叩きレスポンスを View に渡す(あるいは JSON をそのまま通過させる)だけの機能にまで落とし込める。

オーバーヘッド

ここまでやってしまうと気になることが API を叩くために HTTP を経由させることで発生するオーバーヘッド。リクエストを飛ばすことによる CPU 負荷や直列化されたネットワーク IO がボトルネックになりそうなことは容易に想像できる。 CPU 負荷に関してはより高速なライブラリを選ぶなどしてチューニングすべきではあるが…。(他にも内部のトラフィックが増大する可能性もあり、 API サーバは deflate を使って通信を圧縮する必要が有りそうではあるがここでは無視する。インフラの人ごめんなさい)

非同期

直列化されたネットワーク IO がレスポンスタイムに対して大きな比率を占めることは容易に想像できるが、近年のブームを見ているとここは非同期化して解決する方法がわりと簡単に使えるのではないかと思う。リクエストを受けるサーバとして node.js が頭に浮かぶが、 node.js は自由が効かないので選択肢から除外する。(ポイントとなるのは API に対するリクエストの並列化であって、ブラウザからのリクエストに対して全体を非同期化することではない)

非同期化の対象を Controller 内での API に対するリクエストに局所化する理由
  • prefork モデルの方が運用上のノウハウが豊富にある
  • callback地獄を回避する
  • レスポンスタイムが問題になるほど大量にリクエストしている箇所だけをチューニングできる(=最小の努力で最大の効果を得る)
  • 全てが非同期で処理される場合かつ一人の開発者がブロックするコードを混ぜてしまった場合、プロセス全体のパフォーマンスを悪化させてしまう(これだけの設計が必要な規模になるとコミットが大量になるはずで、現実的に考えてデバッグは不可能に近い、気がする)

全体を非同期化することで得られるメリットはこれらを無視してでも手にすべきほど魅力的なものか、現状では考えにくい。

pseudocode
package MyApp::Controller::MyPage;
use strict;
use warnings;
use parent 'MyApp::Handler';
use AE;
use MyApp::UserAgent::AE;

my $ua = MyApp::UserAgent::AE->new;

sub show {
  my ($self, $ctx) = @_;
  my $id = $ctx->request->session->{user_id};

  my $params = {};

  my $cv = AE::cv;

  $cv->begin;
  $ua->get("friends/list", { user_id => $id }, sub {
    my ($res) = @_;
    $params->{friends_list} = $res;
    $cv->end;
  });

  $cv->begin;
  $ua->get("activity", { user_id => $id }, sub {
    my ($res) = @_;
    $params->{activity} = $res;
    $cv->end;
  });

  $cv->begin;
  $ua->get("notifications", { user_id => $id }, sub {
    my ($res) = @_;
    $params->{notifications} = $res;
    $cv->end;
  });

  # ...

  $cv->recv;

  $self->render("mypage", $params);
}

1;

こんな感じに設計できたらいいよねーというお話でした。

疎結合になるとインフラの人も色々と手を入れやすくなるのでデメリットばっかりじゃないよ><


LWP::UserAgent と LWP::Protocol::PSGI でテストを書くと楽できる話

Plack::Test + HTTP ::Request::Common

世の中には Plack::Test + HTTP ::Request::Common という方法もあるが、この場合ブラウザを模したようなテストを書くと意外にも破綻しやすい。とりわけセッション周りの挙動が必須になると大変な手間になる。

LWP::UserAgent + LWP::Protocol::PSGI

最近は LWP::UserAgent + LWP::Protocol::PSGI で楽をするような方法で書くようにしている。ユーザー寄りのテスト(ログイン後の処理やCSRF対策用のトークンが必須等)をわりと楽に書ける点がメリット。

subtest と scope のメリットも享受できる /zento+/ 方式が見通し良く出来そう。( http://d.hatena.ne.jp/zentoo/20120323/1332534159 )

サンプル

Some::Middleware::CSRFDefenderのテストを書くとすればこんな感じ。

use Test::More;

use LWP::UserAgent;
use LWP::Protocol::PSGI;

use Plack::Request;
use Plack::Session;
use Plack::Middleware::Session;

use Some::Middleware::CSRFDefender;

my $raw_app = sub {
    my $env = shift;
    my $ses = Plack::Session->new($env);
    my $req = Plack::Request->new($env);
    my $res = $req->new_response(200);
    $res->body( $ses->get("csrf_token") );
    return $res->finalize;
};

my $url = "http://localhost/";

subtest "Should not work without session middleware" => sub {
    my $app = Some::Middleware::CSRFDefender->wrap($raw_app);
    my $guard = LWP::Protocol::PSGI->register($app);

    my $res = LWP::UserAgent->new->get($url);
    is $res->code, 500;
};

subtest "Basic test cases" => sub {
    # Prepare environments for testing
    my $app = Plack::Middleware::Session->wrap(
        Some::Middleware::CSRFDefender->wrap(
            $raw_app, error_message => "Forbidden"
        )
    );

    my $guard = LWP::Protocol::PSGI->register($app);
    my $ua = LWP::UserAgent->new( cookie_jar => {} );

    my $token = $ua->get($url)->content;

    subtest "returns the same token for the same client" => sub {
        my $res = $ua->get($url);
        is $res->code, 200;
        is $res->content, $token;
    };

    subtest "returns different token for another client" => sub {
       my $another_token = LWP::UserAgent->new->get($url)->content;
       isnt $another_token, $token;
    };

    subtest "POST with valid token" => sub {
        my $res = $ua->post($url, [csrf_token => $token]);
        is $res->code, 200;
        is $res->content, $token;
    };

    subtest "POST with invalid token" => sub {
        my $res = $ua->post($url, [csrf_token => 'invalid token']);
        is $res->code, 403;
        is $res->content, 'Forbidden';
    };

    subtest "POST without token" => sub {
        my $res = LWP::UserAgent->new->post($url);
        is $res->code, 403;
        is $res->content, 'Forbidden';
    };

};

done_testing;

« 2 »