大学院中退するゴミが Expo (React Native) で Twitter ログインしてツイートするまでを誰よりも丁寧に説明するから読んでくれ【即ツイートくん】

# はじめに

どうも [yoshii](https://twitter.com/ganja_tuber) です。
普段は趣味でアプリやサイトを作っているゴミです。

最近"即ツイートくん"というアプリを作りました。
ほんまにエグい速度でツイートできます。
結構便利なのでぜひ使ってください。

iOS

http://apps.apple.com/jp/app/mr-rapid-tweet/id1631496965

Android

https://play.google.com/store/apps/details?id=com.yoshiishunichi.rapidtweet

# 今回紹介するもの

Expo で Twitter ログインする方法を説明する日本語の記事なかったので、俺がやります。

こんな感じでログインしてツイートできます。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/1918f769-5745-949d-2b2d-bef23caaedab.gif)

# 公式サンプル

JavaScript なのとバックエンドが `express` なのが微妙ですが、一応 Expo から公式サンプルが提供されています。
今回のコードもこれを参考に作りました。

https://github.com/expo/examples/tree/master/with-twitter-auth

# GitHub

コードだけ見たい人はこのリポジトリ見てください。
`src/utils/datas.ts` の API_KEY と API_SECRET を自分で取得したキーに置き換えると動作確認ができるはずです。

https://github.com/yoshiishunichi/sample-expo-nestjs-login

# 技術構成

- OS: macOS Monterey
- 言語: TypeScript
- node: v16.15.0
- パッケージマネージャ: yarn
- フレームワーク: 
    - バックエンド: NestJS
    - モバイルアプリ: React Native (Expo)

# 前提条件

## Twitter Developer Account

[Twitter Developer ページ](https://developer.twitter.com/en/portal/dashboard) から Developer account を取得しておいてください。
Twitter 連携アプリを作る時は絶対に必要になるので持っておきましょう。

以下の記事なんかを参考に必要事項を英語で入力して承認されたら Twitter API を使えるようになります。

https://qiita.com/newt0/items/66cb76b1c8016e9d0339

https://qiita.com/ichi_zamurai/items/b129b23f048e4ce76b97

正直、この審査は運ゲーな気がしています。頑張ってください。

## Twitter APIキーを取得

上述した [Twitter Developer ページ](https://developer.twitter.com/en/portal/dashboard) から Twitter アプリを作成し、APIキーを取得してください。
v1.1 しか使わないです。

## Nest CLI の導入

以下の公式ドキュメントの通り Nest CLI を入れておきましょう。

https://docs.nestjs.com/

## Expo CLI の導入から環境構築まで

以下の公式ドキュメントの通り Expo CLI を入れておきましょう。

https://docs.expo.dev/workflow/expo-cli/

あとは Xcode 入れたりして、テンプレートプロジェクトをシミュレーターで起動し動作確認できるようにしましょう。

割と面倒な気がするので、こういう賢い人が書いた記事を読みましょう。

https://zenn.dev/kai/articles/reactnative-typescript-expo-devlop-env

# ログイン機能の作成

## Twitter APIの認証設定

[Twitter Developer ページ](https://developer.twitter.com/en/portal/dashboard) のアプリの設定の User authentication settings からログインできるようにしておきましょう。

![スクリーンショット 2022-07-04 20.37.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/2576097f-970d-f57f-2391-f40c9fff505a.png)

Editを押して、 OAuth 1.0a を有効にします。

![スクリーンショット 2022-07-04 20.38.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/d5461e7d-93b8-55fb-2cd9-0da145908ece.png)

次に、Read and write の権限を追加します。

![スクリーンショット 2022-07-04 20.38.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/a7a593e0-357f-b145-3031-6030888c3601.png)

次に、コールバックURLを設定します。
以下の4つを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/b038fa95-5c3d-2568-308d-0f9a6ab9d63f.png)

- `http://127.0.0.1:19000/`
- `exp://`
- `http://localhost:19000/`
- `https://auth.expo.io/@{Expo のユーザーID}/{slug}`

4つ目は `expo login` しているアカウントのユーザーIDと、 `app.json` の `slug` が必要です。
よくわからない時は、 後で出てくる `makeRedirectUri` 関数で生成されるURLを入力すればOKです。

Website URL は今回サンプルなので適当に Twitter の URL でも入れときましょう。

![スクリーンショット 2022-07-04 20.46.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/0ddf5de0-c860-85fb-f97a-51a2a31b2a89.png)


## Nest CLI でプロジェクトを作成

以下のコマンドでプロジェクトを初期化します。
プロジェクト名は `login-server` とします。

```
nest new login-server
```

パッケージマネージャーを聞かれるので好きなのを選んでください。
筆者は `yarn` を選びます。
しばらく待つとプロジェクトができます。

プロジェクトができたら `src/main.ts` を編集します。
デプロイした時用です。 

```src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT || 3000);
}
bootstrap();
```

以下のコマンドでローカルサーバーが起動します。
http://localhost:3000/ にアクセスして Hello World! って表示されるはずです。

```
yarn start:dev
```

## ログイン用のエンドポイントを用意

Twitter API のラッパーは `twitter-api-v2` を使います。
ライブラリのリポジトリです。

https://github.com/plhery/node-twitter-api-v2

別にラッパーは好きなのを使ってくれたらいいです。 `twitter-lite` とかでも大丈夫です。

以下のコマンドでパッケージを入れます。

```
yarn add twitter-api-v2
```

まずは、各種トークンの情報を定数で用意します。
本来は `.env` とかで管理するのが良いかもしれないですが、面倒なのでこんな感じにしちゃいます。

```src/utils/datas.ts
export const appKey = {APIキー};
export const appSecret = {APIシークレット};
```

次に、 `src/app.service.ts` を以下のように編集します。

```src/app.service.ts
import { Injectable } from '@nestjs/common';
import { TwitterApi } from 'twitter-api-v2';
import { appKey, appSecret } from './utils/datas';

@Injectable()
export class AppService {
  getClient() {
    return new TwitterApi({
      appKey,
      appSecret,
    });
  }

  async getRequestToken(callBackUrl: string) {
    return await this.getClient().generateAuthLink(callBackUrl, {
      linkMode: 'authorize',
    });
  }

  async getAccessToken(
    access_token_key: string,
    access_token_secret: string,
    oauth_verifier: string,
  ) {
    const twitter = new TwitterApi({
      appKey,
      appSecret,
      accessToken: access_token_key,
      accessSecret: access_token_secret,
    });
    const { userId, screenName, accessToken, accessSecret } =
      await twitter.login(oauth_verifier);
    return {
      userId,
      screenName,
      accessToken,
      accessSecret,
    };
  }
}
```

そして、 `src/app.controller.ts` を編集してエンドポイントを用意します。

```src/app.controller.ts
import { Controller, Get, Req } from '@nestjs/common';
import { AppService } from './app.service';
import { Request } from 'express';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('request-token')
  async getRequestToken(@Req() request: Request) {
    const { callback_url } = request.query;
    if (!callback_url) return null;
    return this.appService.getRequestToken(callback_url as string);
  }

  @Get('access-token')
  async getAccessToken(@Req() request: Request) {
    const { oauth_token, oauth_token_secret, oauth_verifier } = request.query;
    return await this.appService.getAccessToken(
      oauth_token as string,
      oauth_token_secret as string,
      oauth_verifier as string,
    );
  }
}
```

コードの簡単な説明をすると、まず http://localhost:3000/request-token に GET リクエストを送信することで、ログイン画面を表示するためのトークンが取得できます。
このトークンを使ってアプリ側でログイン画面からログインして得られる oauth_verifier を使って http://localhost:3000/request-token に GET リクエストを送信することで、アクセストークンを取得できるという流れです。
このアクセストークンを使うことでそのユーザーの権限で Twitter API を使えるというわけです。
例えば、 Twitter API で鍵垢のツイートを取得する際、そのアカウントをフォローしてる Twitter アカウントでログインして取得したアクセストークンじゃないと権限がなくてエラーになるって感じです。

## Expoでアプリを作成

### アプリの初期化

以下のコマンドでプロジェクトを初期化します。名前は `login-app` にします。

```
expo init login-app
```

なんか選ばされるんで、以下を選びます。

```
❯   blank (TypeScript)  same as blank but with TypeScript configuration
```

しばらく待つとプロジェクトができます。
以下のコマンドで勝手にブラウザが開きます。

```
cd sample-expo-login/
yarn start
```

著者は iOS 派なので iOS シミュレータで開きます。
さっきブラウザで開いた画面から `Run on iOS simulator` っていうボタンを押してしばらく待ちます。
こんな感じの画面が出てきたら成功です。

![スクリーンショット 2022-07-01 17.56.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/e098f70d-f2a3-944c-6835-4cbe95ff5a0e.png)

### ログイン機能を追加

HTTPクライアントは `ky` を使います。
`ky` のリポジトリです。

https://github.com/sindresorhus/ky

以下のコマンドでインストールしましょう。

```
expo install ky
```

これは好きなやつでいいです。 `axios` でも、デフォルトの `fetch` でも大丈夫です。

以下のコマンドで `expo-auth-session` も入れます。

```
expo install expo-auth-session
```

まず、エンドポイントを定数で用意します。
さっき NestJS で作ったやつですね。

```utils/endpoints.ts
const API_BASE_URL = "http://localhost:3000/";

export const API_REQUEST_TOKEN_URL = `${API_BASE_URL}request-token`;
export const API_ACCESS_TOKEN_URL = `${API_BASE_URL}access-token`;

export const AUTH_ENDPOINT = "https://api.twitter.com/oauth/authorize";
```

適当に API のレスポンスの型を定義しときます。

```types/index.d.ts
export type AuthResult = {
  authentication: TokenResponse | null;
  error?: AuthError | null;
  errorCode: string | null;
  params: {
    [key: string]: string;
  };
  type: "error" | "success";
  url: string;
};

export type RequestToken = {
  url: string;
  oauth_token: string;
  oauth_token_secret: string;
  oauth_callback_confirmed: string;
};

export type AccountInformation = {
  userId: string;
  accessToken: string;
  accessSecret: string;
  photoUrl: string;
  screenName: string;
};
```

次に、ログインの処理 `twitterLogin` を作成します。
成功したら userId とか accessToken とかの情報を返却して、エラーが起きたら undefined を返却する関数です。
`makeRedirectUri` 関数でさっきのコールバックURLを作成しています。
さっきわからなかった人は、ここで作られたURLをconsole.logとかして見てみましょう。

```lib/twitter.ts
import { Platform } from "react-native";
import { makeRedirectUri, startAsync } from "expo-auth-session";
import {
  API_ACCESS_TOKEN_URL,
  API_REQUEST_TOKEN_URL,
  AUTH_ENDPOINT,
} from "../utils/endpoints";
import { AccountInformation, AuthResult, RequestToken } from "../types";
import ky from "ky";

export const toQueryString = (params: any) =>
  "?" +
  Object.entries(params)
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(value as any)}`
    )
    .join("&");

export const twitterLogin = async () => {
  try {
    const useProxy = Platform.select({ default: true, web: false });
    const redirect = makeRedirectUri({ useProxy });
    const requestRes = await ky(
      `${API_REQUEST_TOKEN_URL}${toQueryString({
        callback_url: redirect,
      })}`,
      {
        method: "GET",
      }
    );
    const tokens = (await requestRes.json()) as RequestToken;
    const authResponse = await startAsync({
      authUrl: `${AUTH_ENDPOINT}${toQueryString(tokens)}`,
    });
    if (
      authResponse.type === "cancel" ||
      authResponse.type === "locked" ||
      authResponse.type === "error"
    )
      throw new Error("cancel auth");
    const authResponseResult = authResponse as AuthResult;
    if (authResponseResult.params && authResponseResult.params.denied)
      throw new Error("cancel auth");
    const { oauth_token, oauth_token_secret } = tokens;
    const accessRes = await ky(
      `${API_ACCESS_TOKEN_URL}${toQueryString({
        oauth_token,
        oauth_token_secret,
        oauth_verifier: authResponseResult.params.oauth_verifier,
      })}`,
      {
        method: "GET",
      }
    );
    return (await accessRes.json()) as AccountInformation;
  } catch (e) {
    console.log(e);
    return undefined;
  }
};
```

最後に画面を作成します。
ログインが完了したら ID とトークンが確認できます。

```App.tsx
import { StatusBar } from "expo-status-bar";
import { useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { twitterLogin } from "./lib/twitter";
import { AccountInformation } from "./types";

export default function App() {
  const [accountInfo, setAccountInfo] = useState<AccountInformation>();

  const onPressLoginButton = async () => {
    setAccountInfo(await twitterLogin());
  };

  return (
    <View style={styles.container}>
      <StatusBar style="auto" />
      <Text>ボタンを押してログインしてね。</Text>
      <TouchableOpacity onPress={onPressLoginButton} style={styles.button}>
        <Text style={styles.buttonLabel}>Login</Text>
      </TouchableOpacity>
      {accountInfo && (
        <View>
          <Text>{accountInfo.screenName}</Text>
          <Text>{accountInfo.accessToken}</Text>
          <Text>{accountInfo.accessSecret}</Text>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
  button: {
    borderColor: "lightgray",
    borderWidth: 2,
    borderRadius: 10,
    backgroundColor: "#00acee",
    width: 200,
    height: 40,
  },
  buttonLabel: {
    color: "white",
    fontWeight: "bold",
    fontSize: 24,
    textAlign: "center",
    textAlignVertical: "center",
    lineHeight: 32,
  },
});
```

これでひとまずログイン機能は作成完了です！

# ツイート機能の作成

## ツイート用のエンドポイントを作成

NestJSプロジェクトの方でツイート用のエンドポイントを作成していきます。
まずはサービスで処理を追加します。

```src/app.service.ts
import { Injectable } from '@nestjs/common';
import { TwitterApi } from 'twitter-api-v2';
import { appKey, appSecret } from './utils/datas';

@Injectable()
export class AppService {
  getClient() {
    return new TwitterApi({
      appKey,
      appSecret,
    });
  }

  async getRequestToken(callBackUrl: string) {
    return await this.getClient().generateAuthLink(callBackUrl, {
      linkMode: 'authorize',
    });
  }

  async getAccessToken(
    access_token_key: string,
    access_token_secret: string,
    oauth_verifier: string,
  ) {
    const twitter = new TwitterApi({
      appKey,
      appSecret,
      accessToken: access_token_key,
      accessSecret: access_token_secret,
    });
    const { userId, screenName, accessToken, accessSecret } =
      await twitter.login(oauth_verifier);
    return {
      userId,
      screenName,
      accessToken,
      accessSecret,
    };
  }

  async postTweet(accessToken: string, accessSecret: string, text: string) {
    const twitter = new TwitterApi({
      appKey,
      appSecret,
      accessToken,
      accessSecret,
    });
    await twitter.v1.tweet(text);
  }
}
```

そして、コントローラーでエンドポイントを定義します。

```src/app.controller.ts
import { Controller, Get, Post, Req } from '@nestjs/common';
import { AppService } from './app.service';
import { Request } from 'express';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('request-token')
  async getRequestToken(@Req() request: Request) {
    const { callback_url } = request.query;
    if (!callback_url) return null;
    return this.appService.getRequestToken(callback_url as string);
  }

  @Get('access-token')
  async getAccessToken(@Req() request: Request) {
    const { oauth_token, oauth_token_secret, oauth_verifier } = request.query;
    return await this.appService.getAccessToken(
      oauth_token as string,
      oauth_token_secret as string,
      oauth_verifier as string,
    );
  }

  @Post('tweet')
  async postTweet(@Req() request: Request) {
    const { accessToken, accessSecret, text } = request.body;
    await this.appService.postTweet(accessToken, accessSecret, text);
  }
}
```

## アプリにツイート機能を追加

Expo アプリの方でツイート機能を追加していきます。
まずは `utils/endpoints.ts` にさっき作ったエンドポイントを追加しましょう。

```utils/endpoints.ts
const API_BASE_URL = "http://localhost:3000/";

export const API_REQUEST_TOKEN_URL = `${API_BASE_URL}request-token`;
export const API_ACCESS_TOKEN_URL = `${API_BASE_URL}access-token`;
export const API_TWEET_URL = `${API_BASE_URL}tweet`;

export const AUTH_ENDPOINT = "https://api.twitter.com/oauth/authorize";
```

次に、 `lib/twitter.ts` に `postTweet` 関数を作成します。
さっき作ったエンドポイントに POST リクエストを送るだけですね。

```lib/twitter.ts
import { Platform } from "react-native";
import { makeRedirectUri, startAsync } from "expo-auth-session";
import {
  API_ACCESS_TOKEN_URL,
  API_REQUEST_TOKEN_URL,
  API_TWEET_URL,
  AUTH_ENDPOINT,
} from "../utils/endpoints";
import { AccountInformation, AuthResult, RequestToken } from "../types";
import ky from "ky";

export const toQueryString = (params: any) =>
  "?" +
  Object.entries(params)
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(value as any)}`
    )
    .join("&");

export const twitterLogin = async () => {
  try {
    const useProxy = Platform.select({ default: true, web: false });
    const redirect = makeRedirectUri({ useProxy });
    const requestRes = await ky(
      `${API_REQUEST_TOKEN_URL}${toQueryString({
        callback_url: redirect,
      })}`,
      {
        method: "GET",
      }
    );
    const tokens = (await requestRes.json()) as RequestToken;
    const authResponse = await startAsync({
      authUrl: `${AUTH_ENDPOINT}${toQueryString(tokens)}`,
    });
    if (
      authResponse.type === "cancel" ||
      authResponse.type === "locked" ||
      authResponse.type === "error"
    )
      throw new Error("cancel auth");
    const authResponseResult = authResponse as AuthResult;
    if (authResponseResult.params && authResponseResult.params.denied)
      throw new Error("cancel auth");
    const { oauth_token, oauth_token_secret } = tokens;
    const accessRes = await ky(
      `${API_ACCESS_TOKEN_URL}${toQueryString({
        oauth_token,
        oauth_token_secret,
        oauth_verifier: authResponseResult.params.oauth_verifier,
      })}`,
      {
        method: "GET",
      }
    );
    return (await accessRes.json()) as AccountInformation;
  } catch (e) {
    console.log(e);
    return undefined;
  }
};

export const postTweet = async (
  { accessToken, accessSecret }: AccountInformation,
  text: string
) => {
  await ky.post(API_TWEET_URL, {
    json: {
      accessToken,
      accessSecret,
      text,
    },
  });
};
```

最後に画面を作成します。

```App.tsx
import { StatusBar } from "expo-status-bar";
import { useState } from "react";
import {
  Alert,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from "react-native";
import { postTweet, twitterLogin } from "./lib/twitter";
import { AccountInformation } from "./types";

export default function App() {
  const [accountInfo, setAccountInfo] = useState<AccountInformation>();
  const [text, setText] = useState<string>("");

  const onPressLoginButton = async () => {
    setAccountInfo(await twitterLogin());
  };

  const onPressTweetButton = async () => {
    try {
      accountInfo && (await postTweet(accountInfo, text));
      setText("");
      Alert.alert("ツイート完了！");
    } catch (e) {
      console.log(e);
      Alert.alert("ツイート失敗...");
    }
  };

  return (
    <View style={styles.container}>
      <StatusBar style="auto" />
      {accountInfo ? (
        <View>
          <TextInput
            value={text}
            onChangeText={(value) => setText(value)}
            style={styles.input}
          />
          <TouchableOpacity onPress={onPressTweetButton} style={styles.button}>
            <Text style={styles.buttonLabel}>ツイートする</Text>
          </TouchableOpacity>
        </View>
      ) : (
        <View>
          <Text>ボタンを押してログインしてね。</Text>
          <TouchableOpacity onPress={onPressLoginButton} style={styles.button}>
            <Text style={styles.buttonLabel}>Login</Text>
          </TouchableOpacity>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
  button: {
    borderColor: "lightgray",
    borderWidth: 2,
    borderRadius: 10,
    backgroundColor: "#00acee",
    width: 200,
    height: 40,
  },
  buttonLabel: {
    color: "white",
    fontWeight: "bold",
    fontSize: 24,
    textAlign: "center",
    textAlignVertical: "center",
    lineHeight: 32,
  },
  input: {
    borderWidth: 1,
    borderColor: "black",
    borderRadius: 5,
    fontSize: 18,
    padding: 2,
  },
});
```

これで `TextInput` に入力した文字をツイートできるはずです！

# まとめ

Expo + NestJS で Twitter ログインする方法を丁寧に説明してみました。
わからないところとかあったらコメントで質問してください。
一緒に考えます。

あと、友達が少ないのでよかったら友達になってくれると嬉しいです…
この記事見てきたって言ってくれたらフォロー返せると思います。

https://twitter.com/ganja_tuber/

それではまた〜