狛ログ

2022年1月11日火曜日

【Laravel】Jetstream + Fortify を画面無し(APIとして)で運用する。

1月 11, 2022

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

Webシステムにおける認証機能、重要ですが、作成するの大変ですよね・・・・。
その悩みを解決するのがLaravelでは、「Jetstream + Fortify」な訳ですが、今回、「フロントとバックエンドを完全に分離したいので、認証機能はAPIで呼び出す方式にして欲しい」との要望があり、Jetstream + Fortifyを画面無し(APIとして)で実装することになったので、記録に残しておこうと思います。

(1)各バージョンと前提

まずは、バージョンですが、
・Laravel 8.27.0
・PHP 8.0.9
となります。

そして、前提条件ですが、
Laravel Jetstreamの設定が完了していて、画面を経由してのログインなどは出来ている
とします。

(2)Fortify設定値変更

早速対応方法としては・・・・Fortifyの設定値の変更となり、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
で、完了、以上。
・・・・としたいところなのですが、そうも行きません。

(3)パスワードリセットのルート定義追加

まずは、本家サイトにも記載がありますが、パスワードリセットのルートは定義する必要があります。
[routes/web.php]
/*
| FortifyのViewを使用しない場合でも、「password.reset」のルートは定義する必要がある。
| https://laravel.com/docs/8.x/fortify#disabling-views-and-password-reset
*/
Route::get('/reset-password/{token}', ResetPasswordController::class)
    ->name('password.reset');

ResetPasswordControllerは新規で作成します
[app/Http/Controllers/ResetPasswordController.php](新規作成)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;

class ResetPasswordController extends Controller
{
    public function __invoke(Request $request)
    {
        // メールアドレスとトークンを返却する
        $result = [
            'email' => $request->email,
            'token' => $request->token
        ];
        return response()->json($result);
    }
}

さて、ここで疑問なのですが、なぜ、ルート定義する必要があるのでしょうか?
それを説明する為に、LaravelとFortifyの中を覗いてみましょう
[vendor/laravel/fortify/routes/routes.php](抜粋)
    if (Features::enabled(Features::resetPasswords())) {
        if ($enableViews) {
            Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.request');

            Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.reset');
        }

        Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.email');

        Route::post('/reset-password', [NewPasswordController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.update');
    }
抜粋して記載していますが、問題の「password.reset」、ありますね。
ただ、よく見てみると、「if ($enableViews) {」となっているので、Viewが無効になっていると定義されない、と言うことが分かります。

なるほど、だから、自分達で定義する必要があると。では、なぜ定義が必要なのか、さらに見てみると、
[vendor/laravel/framework/src/Illuminate/Auth/Notifications/ResetPassword.php](抜粋)
    public function toMail($notifiable)
    {
        if (static::$toMailCallback) {
            return call_user_func(static::$toMailCallback, $notifiable, $this->token);
        }

        if (static::$createUrlCallback) {
            $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
        } else {
            $url = url(route('password.reset', [
                'token' => $this->token,
                'email' => $notifiable->getEmailForPasswordReset(),
            ], false));
        }

        return $this->buildMailMessage($url);
    }
これは、パスワードリセットをした時にメールを送る処理ですね。
ここで「password.reset」のルート定義を使ってURLを作っている訳です。
なので、定義は必要になる、ということですね。

これで、今度こそ画面無し(API化)完了・・・・とならないんですね、これが。

(4)未認証・認証済みの処理追加

ちょっと微妙な調整が必要になります。
そもそもの動きとして、画面有りのJetstream + Fortifyでは、
・ログイン状態で、ログイン画面に遷移しようとすると、ホーム画面へリダイレクトする
・未ログイン状態で、ログインが必要な画面に遷移すると、ログイン画面へリダイレクトする
という動きになります。

実はこの動き、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
の設定をしても変わりません。

API化した状態で考えると、
・バックエンド側でログイン状態(認証済み)の時に、フロントからログインAPIなど未ログイン状態が前提のAPIを呼ばれると、ホーム画面へのリダイレクト指示をレスポンスする
・バックエンド側で未ログイン状態(未認証)の時に、ログイン済み必須なAPIを呼ばれると、ログイン画面へのリダイレクト指示をレスポンスする
となってしまいます。

これは何とかしたいですね。

という事で、まずは前者の
認証済みの時に、ログインAPIなど未ログイン状態が前提のAPIを呼んだ場合、です。

この場合、MiddlewareのRedirectIfAuthenticated.phpに処理が書かれています。
[app/Http/Middleware/RedirectIfAuthenticated.php]
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;

        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
なるほど、確かにホーム画面にリダイレクトしていますね。
では、早速修正して・・・・・
[app/Http/Middleware/RedirectIfAuthenticated.php](修正後)
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;
        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                if ($request->expectsJson()) {
                    // クライアントからJSONレスポンスを要求されている場合、リダイレクトさせず、JSON形式メッセージをレスポンスする。
                    return response()->json(['message' => 'Already authenticated.'], 200);
                }
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
と、こんな感じで、APIとして呼ばれた場合、「Already authenticated.」をレスポンスするようにしました。
後は、フロント側で良きに計らってもらうようにしましょう。

次は、
未認証の時に、ログイン済み必須なAPIを呼ばれた場合、です。
この場合、MiddlewareのAuthenticate.phpに処理が書かれています。
[app/Http/Middleware/Authenticate.php]
    protected function redirectTo($request)
    {
        if (! $request->expectsJson()) {
            return route('login');
        }
    }
あ、でも、これを見る限り、APIとして呼ばれた場合は、ログイン画面のリダイレクトはレスポンスされない気がしますね。
何もレスポンスされないのも困ってしまうので、こちらは、
[app/Http/Middleware/Authenticate.php](修正後)
    protected function redirectTo($request)
    {
        return $request->expectsJson()
            ? response()->json(['message' => 'Unauthenticated.'], 401)
            : route('login');
    }
このように、401エラーとして、Unauthenticated.を返却するようにしました。
こちらも、後は、フロント側で良きに計らってもらうようにしましょう。

ということで、API化完了となります。

細かい部分はまだまだやる事(Viewを消したり、等々)があるのですが、今回はこのぐらいで十分かと思います。

LaravelのJetstream、Fortifyはかなり便利なので、どんどん使っていきたいですね。


【AWS】API GatewayからS3に配置したファイルを直接レスポンスする。

1月 11, 2022
オフィス狛 技術部のyuckieeeです。
今回もまた「API Gateway」を使用したAPI用Mockに関する小ネタをご紹介しようと思います。

本投稿の背景

API Gatewayで快適Mockライフを送る予定だったのですが、思わぬ壁にぶつかりました。
何かというと、データ量の多いJSONデータ(2~6MB程度)を返却しようと統合Mockを設定したところ見知らぬエラーが表示されたのです。
※そんなデータ、JSONで返却するなよ...というツッコミはとりあえず置いておいて下さい(笑)

エラーメッセージ:The resource being saved is too large.Consider reducing the number of modeled parameters, the number of response mappings, or perhaps the size of your VTL templates if used. 何だコレ...と思っていたのですが、どうやらAPI Gatewayにはマッピングテンプレートに設定容量制限があり、そこに引っかかったようです。
私の環境では、マッピングテンプレート128KB辺りを境界にエラーが発生しているようでしたが、公式ページ上のAPIGatewayの制限事項にドンピシャの記述は見つけられませんでした。
ですが、恐らく、この中の何かの制限に引っかかっているものと思います。
公式:REST API の設定および実行に関する API Gateway クォータ

それじゃあ、サイズの大きいデータはどうやって返せば良いのか...
Lambdaを使用することも可能ですが、出来るだけ簡単に対応したいと思ってAPI Gatewayを使用してMockを作成したのに構成が複雑になったら使う意味g(以下略
調査の結果「S3に配置したJSONファイルを直接返却する方法」が一番簡単そうでした。
実際に試すと6MBまでのデータであれば対応可能で、今回の私の要件は満たせそうです。

ということで、 今回はAPI GatewayからS3に直接アクセスしてJSONを返却する方法をご紹介します!

設定手順

以下の作成が完了している前提で、説明割愛いたします。
・S3バケットの作成
・API Gatewayで任意の統合Mock作成(→詳細はコチラで紹介しています!)

Step1: IAMロール作成

デフォルトの状態ではAPI GatewayにS3へのアクセス権限は付与されていません。
そのため、API GatewayにS3へのアクセス許可を与えるためのロールを作成します。

AWSコンソールからIAMの管理画面を開きます。
アクセス管理のロールを選択し、表示された画面右上にある[ロールを作成]を選択して下さい。
[ロールの作成]画面が表示されたら、[信頼されたエンティティの種類を選択]で「AWSサービス」が選択されていることを確認します。
[ユースケースの選択]で[S3(一覧)]>[S3(画面下部)]を選択し、[次のステップ:アクセス権限]ボタンをクリックします。
※今回は、信頼されたエンティティとして「API Gateway」を設定したいのですが、「API Gateway」として用意されているユースケースは、API GatewayからCloud watchにログをアップするというもので、S3へアクセスするユースケースは用意されていないようでした。そのため、本ユースケース設定をベースにカスタマイズします。

次画面の[Attach アクセス権限ポリシー]で、一覧から「AmazonS3ReadOnlyAccess」(※)を選択し、[次のステップ:タグ]ボタンをクリックしてください。
※今回はMockとして使用するため、リクエストがPOSTやPUTであっても、S3上のオブジェクトを読み込みレスポンスとして返却するのみです。そのため、必要最低限である読み取り権限のみのポリシーを選択しています。
タグの追加画面では特に設定変更を行わず[次のステップ:確認画面]をクリックし、確認画面まで遷移してください。
確認画面では以下2項目を設定し、[ロールの作成]ボタンをクリックすれば、ロールが作成されます。

【設定値】
・ロール名:任意
・ロールの説明:任意 ※今回だと「Allows API Gateway to access to S3.」とかでしょうか。

Step2: IAMロールのカスタマイズ

ロールを作成した段階では、ユースケースで選択したとおり「Allows S3 to call AWS services on your behalf.」という、S3が、このロールを使用して任意のAWSサービス(今回はS3)を呼び出すことを想定したものとなっています。
そのため、信頼関係を変更することで「S3」ではなく「API Gateway」が、このロールを使用できるようにします。

作成したIAMロールの概要ページに遷移し、[信頼関係]タブの[信頼関係の編集]ボタンをクリックして信頼関係の編集画面を開きます。
7行目の"Service": "s3.amazonaws.com"の記述を"Service": "apigateway.amazonaws.com"に書き換え、[信頼ポリシーの更新]ボタンをクリックして信頼関係を変更します。
これでAWSサービス「API Gateway」が、該当ロールとして振る舞うことを許可(信頼)されました。

信頼関係の変更後、IAMロールの設定状態を確認します。

[アクセス権限]タブ
「AmazonS3ReadOnlyAccess」権限のみが付与されていることを確認します。
[信頼関係]タブ
信頼されたエンティティに「IDプロバイダー apigateway.amazonaws.com」が設定されていることを確認します。

これで今回使用するロールの作成が完了しました。

Step3:S3にレスポンスファイルを配置する

S3バケットを任意で作成後、管理コンソールからレスポンス用のファイルをアップロードします。
今回は、JSON形式で返却したいため、以下のようなファイルを準備しました。
    {      "result_code" : "0",     "result_message" : "OK"     }

Step4:API Gatatewayの設定を変更する

S3オブジェクトによるレスポンスに変更したい、API Gateway上のリソースを選択します。
[メソッドの実行]画面で[統合リクエスト]をクリックし、設定画面で以下のように設定を変更して[保存]をクリクします。

【設定値】
 ・統合タイプ:AWSサービス
 ・AWSリージョン:ap-notheast-1
 ・AWSサービス:Simpe Storage Service(S3)
 ・AWSサブドメイン:指定なし
 ・HTTPメソッド:GET 
 ・アクションの種類:[パス上書きの使用]にチェック
 ・パス上書き:レスポンス用ファイルのパス ※バケット名/オブジェクト名
 ・実行ロール:今回用に作成したロールを指定(arn:aws:iam::xxxxx)
 ・コンテンツの処理:パススルー※デフォルト設定
 ・デフォルトタイムアウトの使用:チェックオン※デフォルト設定
AWSの統合に切り替えてよいかの確認ダイアログが表示されますので、[OK]をクリックしてください。
これで設定変更は完了です。

Step5: 動作確認&APIのデプロイ

実際に動くかどうか確認を行います。
[メソッドの実行]画面で画面右にある[テスト⚡]をクリックし、[メソッドテスト]画面に遷移します。
[メソッドテスト]画面の左下にある[⚡テスト]ボタンをクリックして、テスト実行してください。
リクエストのレスポンス本文にS3オブジェクトに配置したとおりの内容が返ってきていればOKです。

ここまで確認ができれば、APIのデプロイを行い、作業終了です。
実際にMockとして呼び出して、テストしてみてください。

おまけ:レスポンスに画像を指定する

JSONだけではなく画像ファイルを返却したいという場合もあると思います。
今の設定でも、S3上のファイルを画像ファイルに変えれば上手くいくんじゃないの?って感じるかもしれませんが、デフォルトで画像ファイルのレスポンスは制限されているようです。
以下設定手順を軽くご紹介しておきます。

設定手順

該当APIの設定メニューを選択し、設定画面を開きます。
設定画面の下部にある「バイナリメディアイプ」の[+バイナリメディアタイプの追加]を押下することで、入力フィールドが追加されるため、「*/*」(※)を入力して、右下の[変更の保存]を押下して完了です。
※指定する内容は、HTTPヘッダーのContent-Typeと同様の内容でも問題ありませんが、ワイルドカードで指定しておくのが一番楽ちんかと思います。
これでS3の画像オブジェクトを指定しても、問題なくレスポンスできるようになったかと思います。

まとめ

こちらの方が従来のMockツールに近い感じですね。
また、S3のファイルを置き換えれば、レスポンス内容変更のたびにAPIをデプロイし直すという手間も省けて楽な感じがします。
ガッツリテストをするとなると、こちらの方が向いているかな...というのが個人的な感想です。

API Gatewayには、いろいろな使い方があるのがわかってきたので、もっと色々と紹介していきたいと思います!


2021年12月28日火曜日

C# List内の要素同士で一致するものがあるか確認方法。

12月 28, 2021
オフィス狛 技術部のHammarです。

プログラム実装時に、配列やList同士で一致するものがあるかどうかチェックする、というような場合が多々あると思います。
今回はC#でこのチェックをさっときれいに書く方法がないか調べてみて、良さそうな書き方を見つけたので、ご参考程度にちょっと共有したいと思います。

まず単純に、2つのList内に同一のものがあるかどうかチェックするする場合、下記のようなFindAllメソッド等を使えば簡単に確認ができます。
string[] fruitsAry1 = new string[] { "Apple", "Banana", "Orange", "Grape" };
string[] fruitsAry2 = new string[] { "Apple", "Grape", "Grapefruit", "Strawberry" };
List<string> list1 = new List<string>(fruitsAry1);
List<string> list2 = new List<string>(fruitsAry2);
List<string> fruitsList = list1.FindAll(list2.Contains);
結果上記のfruitsListは{ "Apple", "Grape" }になります。

単純なList同士であれば上記のような方法でチェックできますが、例えばListに複数要素が含まれている場合で、ある要素だけ、一致するかどうかをチェックしたい場合は、ちょっと工夫が必要になります。

例えば

■Fruits.cs

namespace Fruits
{
	public class FruitsData
	{
	    public string id { get; set; }
	    public string name { get; set; }
	}
}

■CheckFruits.cs

List<FruitsData> fruitsList1 = new List<FruitsData>()
{
    new FruitsData {Id = "1", Name = "Apple" },
    new FruitsData {Id = "2", Name = "Banana" }
    new FruitsData {Id = "3", Name = "Orange" }
    new FruitsData {Id = "4", Name = "Grape" }
};

List<FruitsData> fruitsList2 = new List<FruitsData>()
{
    new FruitsData {Id = "1", Name = "Apple" },
    new FruitsData {Id = "2", Name = "Grape" }
    new FruitsData {Id = "3", Name = "Grapefruit" }
    new FruitsData {Id = "4", Name = "Strawberry" }
};
上記のような場合でNameが一致するものがあるか、チェックする場合、素直にやると以下のような感じでしょうか
bool isSame = false;
foreach(var fruits1 in fruitsList1)
{
    if(isSame)
    {
        break;
    }
    foreach (var fruits2 in fruitsList2)
    {
        if(fruits2.Name.Equals(fruits1.Name))
        {
            isSame = true;
            break;
        }
    }
}
2つのリストを回して一致するものがあったら抜けるという、すごい単純なものですが、一致するものがあるかどうかのチェックだけのためにこれを書くのはちょっと長いですよね。

もうちょっとだけ簡略化すると、Existsメソッドを使えばこのようにも書けると思います。
bool isSame = false;
foreach(var fruits1 in fruitsList1)
{
    if(fruitsList2.Exists(fruitsItem2 => fruitsItem2.Name.Equals(fruits1.Name)))
    {
        isSame = true;
        break;
    }
}
結構すっきりしましたが、fruitsList1をとりあえず回さないといけないので、ここまできたらループさせずにいけそうな気がします。
で、上記を踏まえて、さらにFindメソッドを使って、最終的にはこんな感じに簡略化できました。
var machedFruits = fruitsList1.Find(fruitsItem1 => 
    fruitsList2.Exists(fruitsItem2 => fruitsItem2.Name.Equals(fruitsItem1.Name))
);
FindメソッドとExistsメソッドを使ってループを使わずに一致する要素の確認ができました。
ちなみにこのFindメソッドの返り値は、全体の中で最もインデックス番号の小さい要素が返ってきます。
一致するものが無い場合はnullが返ってきますので、単純に一致するかどうかのチェックとしては、nullが返ってこなかったら一致しているということになります。

この他にも、Linqを使ったり等いろいろと方法があると思いますが(もっと簡略化もできそう)、方法の1つとしてご参考いただければと思います。

【Laravel】条件によって、RateLimitterの回数制限のカウント処理を分ける方法。

12月 28, 2021

オフィス狛 技術部のmmm(むー)です。

LaravelでAPIのリクエストをある回数以上行うと、「Too Many Attempts」のエラーが発生して一定時間経過するまでAPIを実行できなくなります。
このエラー文言の表示条件は、RateLimiterにて設定されています。
条件によって、この制限回数のカウント方法を変更する修正がしたかったのですが、対応方法がなかなか見つけられず時間がかかったため記事に残します。

やりたいこと
・ユーザー情報を取得できた場合:1分間に5回連続でAPIをリクエストすると、「Too Many Attempts」のエラーを発生させる
・ユーザー情報を取得できない場合:1時間に10回連続でAPIをリクエストすると、「Too Many Attempts」のエラーを発生させる

Laravelバージョン
$ php artisan --version
Laravel Framework 8.27.0

対処方法

早速ですが、下記のように記載すると実現できました。
RateLimiter::for('login', function (Request $request) {
	return $request->user()
	  ? Limit::perMinute(5)->by($request->user()->id) // ユーザー情報を取得できた場合のリクエスト回数制限
	  : Limit::perHour(10)->by($request->email.$request->ip()); // ユーザー情報を取得できない場合のリクエスト回数制限
}

・制限回数のチェックをする際は、by()でどの単位でカウントするかを指定します。
   ・ユーザー情報を取得できた場合:ユーザーID単位($request->user()->id)でカウント
   ・ユーザー情報を取得できない場合:リクエストのメールアドレス+IPアドレス($request->email.$request->ip())でカウント
・1分間での回数制限を設定する場合は、perMinute()を使用します。
・1時間での回数制限を設定する場合は、perHour()を使用します。

参考:https://laravel.com/docs/8.x/routing#segmenting-rate-limits (公式)

その他
確認する際は、キャッシュをクリアしてください。
$ php artisan cache:clear

またRateLimiterの修正が反映されているか、レスポンスヘッダーから確認することができます。
・X-RateLimit-Limit:全何回リクエストできるか
・X-RateLimit-Remaining:あと何回リクエストできるか
・Retry-After:何秒後にロックが解除されるか


以上となります、参考になりましたら幸いです。

2021年12月27日月曜日

【Python】loggingでログをカスタマイズする【Flask】

12月 27, 2021

狛ログをご覧のみなさん、はじめまして。
2021年にオフィス狛に入社いたしました、nago(なご)です。
技術に関するブログ記事を書くのは初めてですが、有益な情報を投稿できるように頑張ります。
よろしくおねがいします☺

さて、今回はPython(Flask)で、お試し開発をしていた時に苦戦したログ出力についてまとめたいと思います。

作ろうとしていたのは、外部のREST APIにリクエストを投げて、何かしらの結果を受け取り、画面に表示させる機能です。
まず、先輩社員が投稿してくださっていたこちらの記事を参考にして、AWS API GatewayでAPIのMockを作成しました。

正しい合言葉をidに設定してリクエストすると、ステータスコードが200となり、とってもタメになる情報を受け取れるAPIにしました。
合言葉を間違えていたらステータスコードが401となりエラーが返ってきます。

  

  
↓ ↓ ↓
合言葉を間違えると401エラーとなり、以下のメッセージが返ってきます。
  
アプリケーションは以下のような構成で作成しました。
 	 
     api-test/
         ├ static/
         │ └ styles.css
         ├ templates/
         │ └ api_test.html
         ├ views/
         │ ├ __init__.py
         │ └ api_test.py
         ├ __init__.py
         ├ app.py
         ├ get-pip.py
         ├ Pipfile
         └ Pipfile.lock
   	 
  	

コードはこちら。
・api_test.html
    
      <!doctype html>
      <html lang="ja">

        <head>
            <meta charset="utf-8">
            <title>APIテスト</title>
            <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
        </head>

        <body>
            <form action="{{ url_for('api_test.call') }}" method="POST">
                <input type="text" name="id">
                <input type="submit" value="API CALL">
            </form>
            <input type="button" onclick="location.href=&#39;{{ url_for('api_test.show_api_test') }}&#39;" value="RESET">
            <p>{{ message }}</p>
        </body>
     
  

・views/app.py
    
        from flask import Flask, redirect, url_for
        from views.api_test import api_test

        app = Flask(__name__)

        app.register_blueprint(api_test)
			
        # 最初の画面へリダイレクト
        @app.route("/")
        def redirect_to_api_test():
            return redirect(url_for("api_test.show_api_test"))

        if __name__ == "__main__":
            app.run(host="0.0.0.0", port=8080)
    
  

・api_test.py
 	 
      import requests, json

      from flask import Blueprint
      from flask import render_template, request

      api_test = Blueprint("api_test", __name__)

      # 画面表示(初期 & リセット時)
      @api_test.route("/")
      def show_api_test():
          message = ""
          return render_template(
              "api_test.html",
              message=message
          )

      # APIリクエスト & レスポンス受け取り & 画面表示
      @api_test.route("/call", methods=["POST"])
      def call():

          # APIGatewayでMock作成時に設定されたURL
          url = "https://xxxx.amazonaws.com/api-test/my/info"
          # クエリ文字列を作成
          payload = { "id": request.form.get("id") }
          # リクエストヘッダを作成
          headers = { "Content-Type": "application/json" }
          
        # リクエスト & レスポンス受け取り
        response = requests.post(url, data=json.dumps(payload), headers=headers)
        # レスポンスからメッセージを取り出す
         message = response.json()["message"]

          # 画面遷移 & メッセージ表示
          return render_template(
              "api_test.html",
              message=message
          )
	 
  

これで、APIから返ってきたメッセージをそのまま画面に表示することができました。
次に、APIがコールされるたびに、そのリクエスト内容と結果のログを出力する仕組みを作ろうと思います。

Pythonでログを出力するには、Pythonで用意されているloggingというモジュールを使います。
今回は設定ファイルを既存のファイルとは別に作成して、ログの設定をしてみます。
私はプロジェクトルートに「config」という名前のディレクトリを作り、その中にログの設定ファイルを作りました。
また、ログファイルを出力したかったので、ログファイルを置くための「log」ディレクトリも作成しました。

以下が作成したディレクトリと設定ファイルです。

 	 
     api-test/
         ├ config/
         │ ├ __init__.py
         │ └ logging_config.py
         ├ log/
         ├ static/
         │ └ styles.css
         ├ templates/
         │ └ api_test.html
         ├ views/
         │ ├ __init__.py
         │ └ api_test.py
         ├ __init__.py
         ├ app.py
         ├ get-pip.py
         ├ Pipfile
         └ Pipfile.lock
   	 
  	
※赤字が追加分

・config/logging_config.py
    
    from logging import DEBUG, INFO, config
    import datetime

    # ファイル名に入れる日付フォーマット
    today = datetime.date.today().strftime("%Y-%m-%d");

    config.dictConfig(
        {
            "version": 1,
            # ログフォーマット設定
            "formatters": {
                "formatter": {
                    "format": "%(levelname)s  %(asctime)s [%(module)s] %(message)s"
                }
            },
            # ハンドラー設定
            "handlers": {
                # 標準出力ハンドラー
                "streamHandler": {
                    "class": "logging.StreamHandler",
                    "formatter": "formatter",
                    "level": INFO
                },
                # ファイル出力ハンドラー
                "fileHandler": {
                    "class": "logging.FileHandler",
                    "formatter": "formatter",
                    "level": INFO,
                    # ログファイルを出力したい場所のパスとファイル名を指定
                    "filename":f"xxxx/api-test/log/{today}_api-test.log",
                    "encoding": "utf-8"
                }
            },
            # ロガー設定
            "loggers": {
                "basicLogger": {
                    "handlers": [
                        "streamHandler",
                        "fileHandler"
                    ],
                    "level": INFO,
                    "propagate": 0
                }
            }
        }
    )
    
  

ログの設定は、logging.config.dictConfigに辞書(Dictionary型の設定値)を渡すか、
logging.config.fileConfigにconfigparser形式ファイルを渡すかで設定できますが、
私は今回はdictConfigで設定してみました。

ログを出力するには、まずLogger(ロガー)の取得が必要なのですが、そこにHandler(ハンドラー)を指定することで出力形式の指定をプラスできるようになっています。
また、出力フォーマットも設定したい場合は、ハンドラーに対してFormatter(フォーマッター)を指定します。

ロガー、ハンドラー、フォーマッターの名前は自由につけることが出来ます。
私はロガーは「basicLogger」を作り、そこに標準出力ハンドラーの「streamHandler」と、ファイル出力の「fileHandler」をくっつけました。フォーマッターは「formatter」にしました(そのまんま)

"varsion" : 1 ってなんぞやと思い調べてみたのですが、辞書スキーマのバージョンのことでした。
現在は1しかないので、1を入れておけばいいそうです。こちらはdictConfigに辞書を渡す場合は必須のため、入れないと怒られます。

さて、ようやくapi_test.pyで設定ファイルを読み込んで、ログ出力してみます。

・views/api_test.py
    
    import requests, json
    from logging import getLogger

    from flask import Blueprint
    from flask import render_template, request

    from config import logging_config

    api_test = Blueprint("api_test", __name__)

    # ログ設定ファイル読込
    logging_config
    # ロガー取得
    logger = getLogger("basicLogger")

    # 画面初期表示
    @api_test.route("/")
    def show_api_test():
        message = ""
        return render_template(
            "api_test.html",
            message=message
        )

    # APIリクエスト & レスポンス受け取り & 画面表示
    @api_test.route("/call", methods=["POST"])
    def call():

        # APIGatewayでMock作成時に設定されたURL
        url = "https://xxxx.amazonaws.com/api-test/my/info"
        # クエリ文字列を作成
        payload = { "id": request.form.get("id") }
        # リクエストヘッダを作成
        headers = { "Content-Type": "application/json" }

        # リクエストログ出力
        logger.info("request : url = " + url + ", payload = " + str(payload))

        # リクエスト & レスポンス受け取り
         response = requests.post(url, data=json.dumps(payload), headers=headers)
        # レスポンスからステータスコードとメッセージを取り出す
         status_code = response.status_code
         message = response.json()["message"]

        # レスポンスログ出力
        if status_code == 200:
            logger.info("response : status_code = " + str(status_code) + ", message = " + message)
        else:
            logger.error("response : status_code = " + str(status_code) + ", message = " + message)

        # 画面遷移 & メッセージ表示
        return render_template(
            "api_test.html",
            message=message
        )
    
  
※赤字は追加分

・log/2021-12-15_api-test.log (2021/12/15にログが作成された場合のファイル名)
    
      INFO  2021-12-15 14:23:53,701 [api_test] request : url = https://xxxx.amazonaws.com/api-test/my/info, payload = {'id': 'sushi'}
      INFO  2021-12-15 14:23:53,778 [api_test] response : status_code = 200, message = お寿司はおいしい。猫はかわいい。ポメラニアンもかわいい。
      INFO  2021-12-15 14:24:46,184 [api_test] request : url = https://xxxx.amazonaws.com/api-test/my/info, payload = {'id': ''}
      ERROR  2021-12-15 14:24:46,254 [api_test] response : status_code = 401, message = 合言葉が違うようです・・・
    
  
正常パターンとエラーパターンを実行してみました。
フォーマッターで設定した「%(levelname)s %(asctime)s [%(module)s] %(message)s」の通りに表示されています。
出力時にapi_test.pyで設定したメッセージが「%(message)s」に代入されている形です。

調べてみると、loggingは思ったより奥が深くびっくりしました。
覚えておくと出したいログを見やすく出すことができて便利だと思います。

今回はこれでおしまいです。
ここまでお読みいただきありがとうございました。

参考リファレンス:
Logging HOWTO
logging.config --- ロギングの環境設定

Angular・ngx-scannerでインカメラが使われるのを制御する方法。

12月 27, 2021

こんにちは、オフィス狛 技術部のpinoです。


Angularで、バーコードやQRコード読み取りに使用するライブラリに、『ngx-scanner』があります。

テンプレートに<zxing-scanner></zxing-scanner>を埋め込むだけで、読み取り用のカメラを自動で起動してくれる便利なライブラリです。


当社のプロジェクトでも採用しているのですが、先日、通常なら背面のカメラが起動するところで、iOS12でのみインカメラが起動する事象が発生しました。


今回は、その時のトラブルシューティングとして、インカメラが使われるのを制御する方法について紹介します。



バージョン情報

  • Angular:12.0.3
  • ngx-scanner:3.0.1


なぜインカメラが起動する?

まず、インカメラが起動してしまう理由について見ていきます。


カメラが起動するまでの流れは、node_modules/@zxing/ngx-scanner/__ivy_ngcc__/fesm2015/zxing-ngx-scanner.jsで確認できます。

ngAfterViewInit()

/**
 * Executed after the view initialization.
 */
ngAfterViewInit() {
    // makes torch availability information available to user
    this.getCodeReader().isTorchAvailable.subscribe(x => this.torchCompatible.emit(x));
    if (!this.autostart) {
        console.warn('New feature \'autostart\' disabled, be careful. Permissions and devices recovery has to be run manually.');
        // does the necessary configuration without autostarting
        this.initAutostartOff();
        return;
    }
    // configurates the component and starts the scanner
    this.initAutostartOn();
}

this.autostartはtrue固定なのでif (!this.autostart)の分岐に入りません。
this.initAutostartOn()に続きます。

initAutostartOn()

/**
 * Initializes the component and starts the scanner.
 * Permissions are asked to accomplish that.
 */
initAutostartOn() {
    return __awaiter(this, void 0, void 0, function* () {
        this.isAutostarting = true;
        let hasPermission;
        try {
            // Asks for permission before enumerating devices so it can get all the device's info
            hasPermission = yield this.askForPermission();
        }
        catch (e) {
            console.error('Exception occurred while asking for permission:', e);
            return;
        }
        // from this point, things gonna need permissions
        if (hasPermission) {
            const devices = yield this.updateVideoInputDevices();
            this.autostartScanner([...devices]);
        }
    });
}

カメラアクセスの権限周りをチェックしています。

許可された状態だと、最終的にthis.autostartScanner([...devices])が実行されます。

autostartScanner()

/**
 * Starts the scanner with the back camera otherwise take the last
 * available device.
 */
autostartScanner(devices) {
    const matcher = ({ label }) => /back|trás|rear|traseira|environment|ambiente/gi.test(label);
    // select the rear camera by default, otherwise take the last camera.
    const device = devices.find(matcher) || devices.pop();
    if (!device) {
        throw new Error('Impossible to autostart, no input devices available.');
    }
    this.device = device;
    // @note when listening to this change, callback code will sometimes run before the previous line.
    this.deviceChange.emit(device);
    this.isAutostarting = false;
    this.autostarted.next();
}

まず、カメラのlabelプロパティにback|trás|rear|traseira|environment|ambienteが含まれるかを見ていますね。

(調べてみたところ、ポルトガル語やスペイン語などで「後方」「環境」といった意味の単語が含まれているようです。)

devicesの中で上記に当てはまる最初のカメラか、当てはまるものがない場合はdevicesの最後尾のカメラが起動される仕組みになっています。


ここまでで、カメラが起動するまでの流れを追うことができました。


次は、iOS12と、比較用に背面カメラが起動するiOS15のカメラ情報を、デモページを使って確認してみます。


デモページでカメラアクセスを許可すると、使用できるカメラのリストが表示されます。

iOS12とiOS15で試した結果、以下のようになりました。

『〇〇カメラ』となっているのがlabelプロパティに当たり、並び順がそのままdevicesの要素の並びになっています。


どちらのOSも、labelback|trás|rear|traseira|environment|ambienteは含まれていません。

その場合、配列の最後尾のカメラが起動されるわけですが、iOS15は背面カメラなのに対し、iOS12は前面側カメラが最後尾になっています。

iOS12でのみインカメラが起動するのは、ここに原因があったんですね。

デモのコードは、公式のGithubか下記からも確認できます。

https://stackblitz.com/edit/zxing-ngx-scanner


ちなみに、iPhoneの設定から『iPhoneの使用言語』をEnglishに変更したところ、

  • 前面カメラ -> Front Camera

  • 背面カメラ -> Back Camera

という風に文言が変わりました。

labelは使用言語に基づくようなので、使用言語を変更することで回避できる、ということになりますが、あまり現実的ではなさそうです...。



インカメラが使われるのを避けるには?

自動起動されるカメラを変更するのは、先ほど確認したような処理の流れ自体を変更しないといけないので、難しいです。

ただ、デモページで確認できるように、カメラの切り替えは可能になっています。

だとしたら、自動起動が終わった後に、背面カメラを起動するよう設定し直すのはどうでしょうか?


ということで、今度は「自動起動が完了したことを検知できるか」を確認してみます。


ngx-scannerでは、いくつかのイベントが用意されています。

今回使用しているVer.3.0.1では、以下のイベントが定義されていました。

  • autostarted

  • autostarting

  • torchCompatible

  • scanSuccess

  • scanFailure

  • scanError

  • scanComplete

  • camerasFound

  • camerasNotFound

  • permissionResponse

  • hasDevices

  • deviceChange

名前からなんとなくどんなイベントか予想がつくかと思います。


例えば、「使用できるカメラが存在していない場合」と「読み取りに成功した場合」にイベントハンドラを設けたい時は、以下のようにします。

テンプレート側

<zxing-scanner
    (camerasNotFound)="onCamerasNotFound()"
    (scanSuccess)="onScanSuccess($event)">
</zxing-scanner>

ロジック側

onCamerasNotFound(): void {
    console.log('cameras not found.')
}

onScanSuccess(result: string): void {
    console.log('scan success:', result)
}

ここで、カメラが起動するまでの流れの最後の処理(autostartScanner())をもう一度見てみます。

/**
 * Starts the scanner with the back camera otherwise take the last
 * available device.
 */
autostartScanner(devices) {
    const matcher = ({ label }) => /back|trás|rear|traseira|environment|ambiente/gi.test(label);
    // select the rear camera by default, otherwise take the last camera.
    const device = devices.find(matcher) || devices.pop();
    if (!device) {
        throw new Error('Impossible to autostart, no input devices available.');
    }
    this.device = device;
    // @note when listening to this change, callback code will sometimes run before the previous line.
    this.deviceChange.emit(device);
    this.isAutostarting = false;
    this.autostarted.next();  // <- ここ!
}

最後に、autostartedイベントが発火していますね。

これを、コンポーネント側で検知するようにしてあげればよさそうです。

テンプレート側

<zxing-scanner
    [(device)]="currentDevice"
    (camerasFound)="onCamerasFound($event)"
    (autostarted)="onAutoStarted()">
</zxing-scanner>

ロジック側

availableDevices: MediaDeviceInfo[];
currentDevice: MediaDeviceInfo = null;

...

onCamerasFound(devices: MediaDeviceInfo[]): void {
    // 使用できるカメラを取得
    this.availableDevices = devices;
}

onAutoStarted(result: string): void {
    const userAgent = navigator.userAgent.toLowerCase();
    let iosVersionInfo = userAgent.match(/iphone os ([\d]+)_([\d]+)_([\d]+)/);
    if (!iosVersionInfo) {
        iosVersionInfo = userAgent.match(/iphone os ([\d]+)_([\d]+)/);
    }
    const iosVersion = iosVersionInfo?.[1];

    if (iosVersion === '12') {
        // iOS12の場合は、配列の最初のカメラを使用する
        this.currentDevice = this.availableDevices?.[0];
    }
}

実際に、iOS12の場合は背面カメラを起動するよう設定し直すサンプルにしてみました。


まず、camerasFoundで使用できるカメラの情報が渡ってきますので、変数に控えます。

テンプレート側にある[(device)]プロパティに使用したいカメラを設定してあげるとカメラが切り替えられるので、UserAgentからiOS12と判断できる場合は配列の最初のカメラを設定するようにしました。


iOS12ではない場合はライブラリ側で自動起動したカメラが設定されますので、ピンポイントな修正が可能です。



INFO

最新版(Ver.3.2.0)では、this.autostarted.next()initAutostartOn()内に処理が移っていました。

紹介した処理の流れは大きく変わっていないと思うので、お使いのバージョンと比較してみてください。



以上が、今回のトラブルシューティングの内容でした。

どなたかの参考になれば嬉しいです🦓


iOS15版のSafariから目立つようになったステータスバーの背景色を指定したい。

12月 27, 2021

こんにちは、オフィス狛 デザイン部のSatoです。



iOS15版のSafariで以前私がHTMLなどを作成したWebシステムを確認したところ、ステータスバーの色が変わっていることに気づきました。

自動的に選ばれた色とヘッダーの色味が少し違う色でしたので、逆に浮いて見えて困っていました。

ステータスバーをWebシステムのメインカラーの色に合わせられたら違和感を感じないのにと考え、色を制御する方法を探していたところ、色を指定できるようなので忘れないよう備忘録も兼ねてブログに書いてみようと思います。


ステータスバーの背景色を指定する方法、それは「theme-color」というmetaタグを書くだけです。

headタグの中に下記を記述することで色を変えることができます。

<meta name="theme-color" content="任意の色">

任意の色の部分にカラーコードを指定するだけで、ステータスバーやアドレスバーの背景色が変わります。

上記は
<meta name="theme-color" content="#dcdcdc">
をheadタグの中に記述しました。

「theme-color」を指定した場合も、明度の高いペールカラーのような色を指定した場合にはダークモードでは色が変わらないこともあるようですが、それはダークモードの仕様上仕方ないかと思います。



余談ですが、iOS15版のSafariから画面下部にアドレスバーが表示される関係だと思うのですが、要素が少ないページの背景色がアドレスバーの下には回り込まないで下に「theme-color」で指定した色が表示されてしまったりします。

上記のような状態になった場合は、全体を囲んでいるタグにheight: 100vh;を指定すると背景が下まで表示されるようになりました。(余白が空いてしまった例のCSS指定は height: 100%; にしていました)

簡単なことなのですが、少しつまづいてしまったので、似たような現象に悩まれた方は試してみてはいかがでしょうか?


iOS15版のSafariはレイアウト含めさまざまな変更点があり、技術者はもちろん普通に使っているユーザーも覚えることが多くて大変ですね……。

今後もiOS版Safariの仕様変更には気をつけていきたいです。