みんな大好きチャットワークが念願のWebhook&OAuthに対応しました。
あまりにも喜びが高まったので、React Native Androidで投稿サンプルを作ってみました。
もちべ
- 通知系のbotで最も使われるであろう投稿権限(
rooms.messages:write
)のみのアプリケーションを作ってみたい - OAuthとモバイルアプリって相性悪かった気がするけどどうだったか思い出したい
- たまにはReact NativeでAndroidだけに寄った素振りがしたい
やったこと
- アプリケーション登録ページでサンプルアプリを登録
- ↑でリダイレクトURLに登録したやつ(
https://www.example.com/cwrnsampleredirect
)をフックする設定をAndroidManifest.xmlに記載 - App.jsにごりごり処理を書く
つくったもの
概要図
コード
import React, { Component } from 'react';
import {
View,
Button,
Linking,
AsyncStorage
} from 'react-native';
import uuidv4 from "uuid/v4";
export default class App extends Component {
state = {
authorized: false
}
async componentDidMount() {
console.log("componentDidMount");
// intent-filterでフックしたredirect_url(認可コード付き)がここに届く
const initialURL = await Linking.getInitialURL();
console.log(`initialURL: ${initialURL}`);
if (initialURL != null) {
// ブラウザから来たっぽかったら認証を行う
this.authorize(initialURL);
}
}
/**
* コンセント画面のURLを組み立ててブラウザにエイヤする
*/
async startAuth() {
const responseType = "code";
const clientId = "くらいあんとあいでぃー";
const scope = "rooms.messages:write";
const state = uuidv4(); // ランダムならなんでもよかった
// ブラウザから返ってきたときに確認したいので一旦保存
await AsyncStorage.setItem("state", state);
const authURL = `https://www.chatwork.com/packages/oauth2/login.php` +
`?response_type=${responseType}` +
`&client_id=${clientId}` +
`&state=${state}` +
`&scope=${scope}`;
if (!await Linking.canOpenURL(authURL)) {
return;
}
await Linking.openURL(authURL);
console.log("url is open.");
}
/**
* 認証する
* @param {*} initialURL intent-filterで拾ったURL
*/
async authorize(initialURL) {
// search paramsを抽出
// 本当は↓みたいにしたかったけどRNにURL生えてなかった
// const params = new URL(initialURL).searchParams;
const searchParams = new URLSearchParams(initialURL.split("?")[1]);
console.log(searchParams.toString());
if (!searchParams.has('code') || !searchParams.has('state')) {
return;
}
// リクエストしたときと同じ文字列で戻ってきたことを確認する
const state = searchParams.get('state');
const originalState = await AsyncStorage.getItem("state");
if (state !== originalState) {
console.log("hack?");
return;
}
// 一度通れば用済みなので削除
await AsyncStorage.removeItem("state");
// 認証リクエスト
const basic = 'くらいあんとあいでぃー:くらいあんとしーくれっと をBase64したやつ';
const reqParams = {
// 何故かredirect_uriが必須(付けないとinvalid_grantって言われる)
redirect_uri: 'https://www.example.com/cwrnsampleredirect',
grant_type: 'authorization_code',
code: searchParams.get('code')
}
// 涙ぐましいbody組み立て
const reqBody = Object.keys(reqParams).map((key) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(reqParams[key]);
}).join('&');
const response = await fetch("https://oauth.chatwork.com/token", {
method: 'POST',
headers: {
'Authorization': `BASIC ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: reqBody
});
console.log("response", response);
const resBody = await response.json();
console.log("resBody", resBody);
if (resBody.access_token) {
// 成功してたら保存して画面更新
await AsyncStorage.setItem("token", JSON.stringify(resBody));
this.setState({authorized: true});
} else {
alert("認証に失敗しました");
}
}
/**
* メッセージを投稿する
*/
async postMessage() {
const tokenJson = await AsyncStorage.getItem('token');
if (!tokenJson) {
this.setState({authorized: false});
return;
}
const access_token = JSON.parse(tokenJson).access_token;
const roomId = へやばんごう;
const reqParams = {
body: "テスト投稿"
}
// 涙ぐましいbody組み立て
const reqBody = Object.keys(reqParams).map((key) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(reqParams[key]);
}).join('&');
const response = await fetch(`https://api.chatwork.com/v2/rooms/${roomId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: reqBody
});
alert("投稿しました");
}
render() {
let MyButton;
if (this.state.authorized) {
MyButton = () => <Button title="投稿する" onPress={() => this.postMessage()} />
} else {
MyButton = () => <Button title="認証" onPress={() => this.startAuth()} />
}
return (
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<MyButton />
</View>
);
}
}
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- ここを追記↓ -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.example.com" android:path="/cwrnsampleredirect" />
</intent-filter>
<!-- ここを追記↑ -->
</activity>
ざっくり作ったのでところどころコピペが見えるのは勘弁してください。
たのしかったこと・よかったこと
- async/awaitは本当にすごい。楽。
- 単純なURLフックならAndroidManifest.xmlだけで実現できることを再確認できた。楽。
- 投稿できた!!!!!!!!!(重要)
くるしかったこと(CW由来)
https縛り
カスタムスキーマにリダイレクトしてもらえばAndroidでもiOSでもフックできるやん?と思ってやり始めたら、https縛りがあったので雑に作れるのはAndroidだけになりました。
たったこれだけのためにUniversal Links用のファイルを置いたサーバーを立てるほどの暇はないです・・・
application/jsonで送れない
これは元々のAPIがこうだったので、仕方ないといえば仕方ないです。
const reqParams = {
redirect_uri: 'https://www.example.com/cwrnsampleredirect',
grant_type: 'authorization_code',
code: searchParams.get('code')
}
const response = await fetch("https://oauth.chatwork.com/token", {
method: 'POST',
headers: {
'Authorization': `BASIC ${basic}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(reqParams)
});
415 The request's Content-Type is not supported. Expected:↵application/x-www-form-urlencoded or multipart/form-data
application/x-www-form-urlencoded
か multipart/form-data
で寄越せと言われているのでその通りに送りましょう。
ドキュメントにも application/x-www-form-urlencoded
でリクエストするって書いてあります。
multipart/form-data使うとapplication/jsonよこせっていわれる
なんとなくFormDataを使いたかったので、 multipart/form-data
で送ることにしました。
const formData = new FormData();
formData.append("redirect_uri", "https://www.example.com/cwrnsampleredirect");
formData.append("grant_type", 'authorization_code');
formData.append("code", searchParams.get('code'));
const response = await fetch("https://oauth.chatwork.com/token", {
method: 'POST',
headers: {
'Authorization': `BASIC ${basic}`,
'Content-Type': 'multipart/form-data'
},
body: formData
});
415 The request's Content-Type is not supported. Expected:↵application/json
なんでや! application/json
関係ないやろ!!!!!!!
いろいろ試しましたがこの挙動はよくわかりませんでした。
実はredirect_uriが必須っぽい
ドキュメントでは
複数のredirect_uriを設定している場合は必須ですが、単一のredirect_uriしか設定されていない場合は任意です。
とあるのですが、実際には付けなかった場合に invalid_grant
エラーが発生します。↓の形が最小構成ということになりますね。
const reqParams = {
redirect_uri: 'https://www.example.com/cwrnsampleredirect',
grant_type: 'authorization_code',
code: searchParams.get('code')
}
コンセント画面へのURLを組み立てる場合にも似たようなルールがありますが、あちらは実際にredirect_uriなしでもちゃんと動くので、こちらが意図しない挙動なのかなと思います。
くるしかったこと(RN由来)
URL無かった
なんかURLをnewすると異常終了するんですよ。searchParamsの取り出し用に使いたかったので残念でした。
URLが使えないの地味に厳しみがある / Creating an instance of URL like: `new URL('https://t.co/VPCsL06rZA')` throws an exception. https://t.co/r0F4k3ikHK
— なかざん@一切完勝 (@Nkzn) 2017年11月1日
FormData使えない
途中で私も錯乱してきて、 application/x-www-form-urlencoded
にFormDataを突っ込むと専用のエラーメッセージが出るという知見を得たりしました。
これ踏んでる #reactnativehttps://t.co/NLDs2JlO9J
— なかざん@一切完勝 (@Nkzn) 2017年11月1日
まとめ
とりあえず初日に動かしてみた報告な記事でした。
私がぶち当たった問題がサーバー由来なのかRN由来なのかは切り分けられてないので、ブラウザから触ればもっと罠にハマらず良い感じの挙動をするかもしれません。(ときどきfetchの実装を疑わないといけないようなプラットフォームで試してしまった私が悪い)
今後はAPIの利用者やAPIを利用したサービスが次々と立ち上がりそうで、楽しみですね!
現場からは以上です。