Ruby on RailsでAndroidアプリ用のAPIを作ってみた

この記事はみす54代 Advent Calendar 2019
みす54代 Advent Calendar 2019 - Adventar
の11日目として書かれてます


はじめに

ウィィィィィッス〜どうも〜54代のHarrisonKawagoeで〜す

元々鉄道関連の話をするつもりだったんですが、いいネタがなかったので結局最近開発しているアプリの話をすることにしました。

 

プロ研発表会を見た人は覚えていると思いますが、僕はあの時OwlCalendarというカレンダーアプリを出しました。この前インターン先にRuby on Railsで構築した自社サービスと通信できるAndroidネィティブアプリの開発を任されたので、それの練習としてOwlCalendarのAndroidアプリ用のAPIを作ってみました。このAPIは、Androidアプリだけではなく、iOSアプリまたは他のプログラミング言語で書いたプログラムからアクセスすることができます(理論的にはできるはず)。今回は、そのAPIの作り方について話したいと思います。
 


ユーザー名とパスワードを認証するControllerの作成

OwlCalendarの場合、クライエントはBasic認証を使ったデータをサーバー側に送り、サーバーが認証結果をクライエントに返してくれます。
このようにユーザーが送ったBasic認証を使ったデータを使って認証し、認証結果を返すControllerを作成します。

まず、以下のコマンドを実行します:

rails g controller loginapi index

これを実行すると、Controllerフォルダーの中にloginapi_controller.rbという名のファイルが作成されます。
同時にGETメソッドの/loginapi/indexルートが作成されますが、今回はクライエントからデータを受け取るので、POSTメソッドに直す必要があります。
configフォルダーの中に入ってるroutes.rbは以下のように記述されていますが:

 get 'loginapi/index'

これを:

 post 'loginapi/index'

に書き換えます
ファイルを保存し、下記のコマンドを実行します:

rake routes

このコマンドを実行すると、/loginapi/indexルートはPOSTメソッドに変わります。
そしてloginapi_controller.rbを下記のように編集します:

 class LoginapiController < ApplicationController

  skip_before_action :verify_authenticity_token #これをつけないと422エラーが発生します

  def index
    authenticate_or_request_with_http_basic do |username, password| #ユーザーから送ってきたBasic認証に使うユーザー名とパスワードの取得
      username == ENV["BASIC_AUTH_USER"] && password == ENV["BASIC_AUTH_PASSWORD"]
      userdatas = User.all
      userdatas.each do |userdata|
        if userdata.name == username #ユーザーが存在したら、認証する
          userobject = User.find(userdata.id.to_i)
          if userobject.authenticate(password) #パスワードが正しいかどうか判断する。OwlCalendarのUserモデルにはbcryptを導入しているので、authenticateメソッドを使うにはGemでbcryptをインストールする必要があります
            session[:username] = userdata.name #ログインしたユーザーの情報をセッションに追加する
            session[:userid] = userdata.id
            
            render :plain => userdata.id.to_s and return #認証が成功したら、ユーザー名に対応するユーザーIDを返す
              
          end
          render :plain => 'Password not Correct!' and return #パスワードが違う時のエラーメッセージ
            
        end
          
      end
    
    end
    
    
    render :plain => 'User not exist!' #ユーザーが存在しない時のエラーメッセージ
  end
end

これで、サーバー側の準備が整えました。


次に、Androidアプリ側に処理を追加します。
ログインに成功したあと、アプリを閉じてもログイン状態を保つ為に、SharedPreferencesを使ってログインしたユーザーの情報を保存します。
OwlCalendarのAndroid版の場合、SharedPreferencesはMainContentsクラスで管理しますので、MainContents.javaに以下のメソッドを追加します:

 public static void setDefaults(String key, String value, Context context) {//キーの追加
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        SharedPreferences.Editor editor = preferences.edit();
        editor.putString(key, value);
        editor.commit();
    }

    public static String getDefaults(String key, Context context) {//キーの取得
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        return preferences.getString(key, null);
    }

    public static void delDefaults(String key, Context context) {//キーの削除
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        preferences.edit().remove(key).apply();
    }

staticは良くないらしいが...便利なのでほっといてください...
ここから行う処理は外部のライブラリーが必要になるので、build.gradle(Module:app)のdependenciesに以下の2行を追加します

implementation 'com.squareup.okhttp3:okhttp:4.0.0-alpha02'
compile 'com.loopj.android:android-async-http:1.4.9'

そしてSyncを行うと、OkhttpとAsyncが使えるようになります。

ログイン画面の処理はLoginActivity.javaで管理しているので、LoginActivity.javaに以下のように編集します:

package com.example.owlcalendar;

import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import com.loopj.android.http.AsyncHttpClient;
import com.loopj.android.http.AsyncHttpResponseHandler;

import org.w3c.dom.Text;

import cz.msebera.android.httpclient.Header;

public class LoginActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.loginpage);
        final EditText usrtxt = findViewById(R.id.usernamebox);//ユーザー名
        final EditText pwdtxt = findViewById(R.id.passwordbox);//パスワード



        Button btn = findViewById(R.id.loginbtn);//ログインボタン
        btn.setOnClickListener(new View.OnClickListener() {//ログインボタンが押されたあとの処理
            @Override
            public void onClick(View view) {
                AsyncHttpClient webclient = new AsyncHttpClient();
                webclient.setBasicAuth(usrtxt.getText().toString(),pwdtxt.getText().toString());//Basic認証としてユーザー名とパスワードを送信フォームに格納

                webclient.post("https://owlcalendar.herokuapp.com/loginapi/index", new AsyncHttpResponseHandler() {//送信とサーバーから戻ってきた結果の処理


                    @Override
                    public void onSuccess(int statusCode, Header[] headers, byte[] responseBody) {//接続が成功した場合
                        String response = new String(responseBody);
                        Log.d("msg","status:"+response);
                        try {
                            Integer i = Integer.valueOf(response);//受け取った内容が数字(ユーザーID)では無い場合、エラーが出てCatchに飛ばされる(つまり認証が成功していない)
                            Toast.makeText(LoginActivity.this, "Login Success!", Toast.LENGTH_LONG).show();
                            MainContents.setDefaults("userid",response,getApplicationContext());//認証が成功した場合、ユーザーIDがMainContentsが管理しているSharedPreferencesに保存される
                            MainContents.setDefaults("username",usrtxt.getText().toString(),getApplicationContext());

                        } catch (Exception e) {
                            Toast.makeText(LoginActivity.this, response, Toast.LENGTH_LONG).show();//エラーメッセージの表示
                        }

                    }

                    @Override
                    public void onFailure(int statusCode, Header[] headers, byte[] responseBody, Throwable error) {//接続が失敗した場合
                        Log.d("msg","status"+statusCode);
                        Toast.makeText(LoginActivity.this, "Error Code:"+statusCode, Toast.LENGTH_LONG).show();

                    }
                });


                Intent intent = new Intent(LoginActivity.this,MainContents.class);//メイン画面へ移動する
                startActivity(intent);


                finish();
            }
        });
    }
}

これでログイン処理の完成です
今回は作ってはいませんが、ログアウトする場合、SharedPreferencesに保存されているユーザー情報とRailsのSessionの中に入ってるユーザー情報をnil(Javaはnull)にすればOKです。



サーバのデータベースに格納されているデータを読み込む

直接Web側にアクセスする場合、DBへの読み書きはRailsのControllerが処理してくれます。しかし、Railsが使っているデータモデルは、Javaや他の言語が直接処理できるものではありません。

さて、どうすればいいのでしょうか?
 
そこで、JSONを使って通信すれば、Rails側が処理することもできますし、Android側もうまく処理することができます。

というわけで、RailsAPIにアクセスした時予定リストをJSONに保存し、そしてJSONをRequestを送信したクライアントにJSONデータを返すControllerを作成します

まず、loginapiと同様に、Railsコマンドを使ってschapiという名のControllerを作成し、ルートをPOSTメソッドに変更します。

次に、schapi_controller.rbを下記のように編集します:

class SchapiController < ApplicationController
  skip_before_action :verify_authenticity_token #これをつけないと422エラーが発生します
  
  def index
    userid = params["userid"] #クライアントのRequestからユーザーIDのパラメータを取得する
    schs = []  #ユーザーIDに対応する予定を格納する配列
    schlist = Schedule.all  #予定モデルから予定データを全部取得
    schlist.each do |sch|
      if sch.userid.to_i == userid.to_i #予定データの中に入ってるユーザーIDがRequestから受け取ったユーザーIDと同じであれば、配列schsに追加する
        schs.push(sch)
      end
    end
    
    render :json => schs #配列schsをJSONに変換し、クライエントに返す
  end
end

これで、クライアントがユーザーIDを/schapi/indexに送信すると、予定リストが入ってるJSONを受け取ることができます。

そして、Android側に受け取ったJSONを処理するメソッドを作ります。
今回は、予定リストはScheduleListという名前のFragmentで見ることができるようにしたいので、SchduleList.javaで以下のように編集します

 package com.example.owlcalendar;

import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;

import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONObject;

import java.io.IOException;
import java.util.ArrayList;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public class ScheduleList extends Fragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {


        View v = inflater.inflate(R.layout.schedulelist,container, false);
        final ArrayList<String> yoteis = new ArrayList<String>();
        //OkHttpClinet生成
        OkHttpClient client = new OkHttpClient();
        String userid = MainContents.getDefaults("userid",getContext());

        if(userid==null){
            userid="";
        }
        //ユーザーIDをRequestBodyに保存し、Requestするときに送る
        RequestBody formBody = new FormBody.Builder()
                .add("userid",userid)
                .build();

        Request request = new Request.Builder()//サーバーへの接続とRequestの送信
                .url("https://owlcalendar.herokuapp.com/schapi/index")
                .post(formBody)
                .build();

        //非同期リクエスト
        client.newCall(request)
                .enqueue(new Callback() {

                    //エラーのとき
                    @Override
                    public void onFailure(@NotNull Call call, @NotNull IOException e) {
                        Log.e("Hoge",e.getMessage());
                    }

                    //正常のとき
                    @Override
                    public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {

                        //返り値の取得
                        final String jsonStr = response.body().string();
                        //Log.d("Tagarray","jsonStr=" + jsonStr);
                        try {
                            JSONArray jarray = new JSONArray(jsonStr);//返り値をJSONオブジェクトの配列に変換する
                            for (int i = 0; i < jarray.length(); ++ i) {
                                JSONObject json = jarray.getJSONObject(i);

                                String content = json.getString("content");//JSONオブジェクトから値を取得する
                                String time = json.getString("timedata");
                                String yotei = "Content:"+content+"Time:"+time;
                                yoteis.add(yotei);//予定をListViewに表示する配列に追加する
                            }
                        }
                        catch (org.json.JSONException e) {

                        }


                        //JSON処理
                        try{
                            //jsonパース
                            JSONObject json = new JSONObject(jsonStr);
                            final String status = json.getString("status");

                            //親スレッドUI更新
                            Handler mainHandler = new Handler(Looper.getMainLooper());
                            mainHandler.post(new Runnable() {
                                @Override
                                public void run() {

                                }
                            });


                        }catch(Exception e){
                            Log.e("Hoge",e.getMessage());
                        }

                    }
                });
        ListView lv = v.findViewById(R.id.schlist);
        ArrayAdapter<String> aa = new ArrayAdapter<String>(getContext(),android.R.layout.simple_expandable_list_item_1,yoteis);
        lv.setAdapter(aa);//ListViewを使って予定内容を表示する
        super.onCreate(savedInstanceState);

        return v;
    }

    }

そして内容を取得することができたらこうなります:
(左:スマホアプリ側 右:Web側)

f:id:HarrisonKawagoe8111F:20191210022447p:plain

(UIがガバガバすぎて申し訳ございません...まだ開発中なので...)


これで、サーバーからデータベースの内容を読みこむことができました


サーバのデータベースに新しいデータを書き込む

まず、さっきと同様にpostschapiという名前のControllerの新規作成と/postschapi/indexルートをPOSTメソッドへの変更を行います。

読み込む場合と同様に、スマホアプリからサーバーのデータベースに書き込む場合も、JSONを利用します。

まず、スマホ側でJSONを新しく作成します。サーバー送りたい内容をJSONに書き込み、JSONをサーバーに送ります。
サーバーがJSONを受け取ったら、スマホ側と同様にJSONオブジェクトの内容を解析し、解析した内容をデータベースに追加します。


postschapi_controller.rbは以下の通りになります:

class PostschapiController < ApplicationController

  skip_before_action :verify_authenticity_token #422エラー対策

  def index
    json_request = JSON.parse(request.body.read) #ユーザーから受け取った内容をJSONオブジェクトに変換する
    if json_request["content"] != nil
       content= json_request["content"] #JSONオブジェクトからパラメーターを取得
      time= json_request["time"]
      userid= json_request["userid"]
      timedata= json_request["timedata"]
      schedule = Schedule.new(content:content,time:time,userid:userid,timedata:timedata) #予定の新規作成
      schedule.save #新しい予定をデータベースに保存する
    end
    
  end
end

スマホ側の送信処理は以下のメソッドを使います:

void postschedule(String urls,final String content,final int time,final int userid,final String timedata) throws Exception{
        final String posturl = urls;
        Thread thread = new Thread(new Runnable()

        {
            @Override
            public void run() {
                try {
                    JSONObject json=new JSONObject();//JSONオブジェクトの新規作成
                    json.put("content",content);
                    json.put("time",time);
                    json.put("userid",userid);
                    json.put("timedata",timedata);
                    String buffer = "";
                    HttpURLConnection con = null;
                    URL url = new URL(posturl);
                    con = (HttpURLConnection) url.openConnection();//サーバーとの接続
                    con.setRequestMethod("POST");
                    con.setInstanceFollowRedirects(false);
                    con.setRequestProperty("Accept-Language", "jp");
                    con.setDoOutput(true);
                    con.setDoInput(true);
                    con.setRequestProperty("Content-Type", "application/json; charset=utf-8");
                    con.connect();
                    DataOutputStream os = new DataOutputStream(con.getOutputStream());
                    os.writeBytes(json.toString());

                    os.flush();
                    os.close();//JSONの書き出しと送信

                    Log.d("post","ResponseCode:"+con.getResponseCode());

                    con.disconnect();
                }catch (Exception e) {
                    e.printStackTrace();
                    Log.d("errtag",e.toString());
                }

            }
        });

        thread.start();
    }

Uriと予定の内容とユーザーIDを引数として渡せばデータベースに書き込むことができます。

最後に

これで、基本的なAPIの開発は完了しました。
ただ、気づいた人はいると思いますが、このやり方は安全性からみるとあんまりよく無いので、もし可能であればPOSTに認証を入れることや、CSRFトークンを有効にすることなどをお勧めします。
OwlCalendarはまだ開発中なので、安全性などの問題があると思われます。
間違った点あるいは改善すべきの点があったら、指摘してくれると幸いです。