From ce695b67faa439ae1fb549e657fce316468bd560 Mon Sep 17 00:00:00 2001 From: jwilson Date: Sat, 22 Oct 2016 13:01:42 -0400 Subject: [PATCH] A WebSockets sample that uses the Slack API. Currently it just prints WebSockets messages as they arrive, never sending any. In a follow-up I'd like to do that, and also start to use this to figure out where and how our WebSockets client can be improved. https://github.com/square/okhttp/issues/2902 --- checkstyle.xml | 4 + pom.xml | 2 +- samples/pom.xml | 1 + samples/slack/pom.xml | 26 ++++ .../main/java/okhttp3/slack/OAuthSession.java | 42 ++++++ .../okhttp3/slack/OAuthSessionFactory.java | 126 ++++++++++++++++++ .../main/java/okhttp3/slack/RtmSession.java | 89 +++++++++++++ .../java/okhttp3/slack/RtmStartResponse.java | 32 +++++ .../src/main/java/okhttp3/slack/SlackApi.java | 125 +++++++++++++++++ .../main/java/okhttp3/slack/SlackClient.java | 102 ++++++++++++++ 10 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 samples/slack/pom.xml create mode 100644 samples/slack/src/main/java/okhttp3/slack/OAuthSession.java create mode 100644 samples/slack/src/main/java/okhttp3/slack/OAuthSessionFactory.java create mode 100644 samples/slack/src/main/java/okhttp3/slack/RtmSession.java create mode 100644 samples/slack/src/main/java/okhttp3/slack/RtmStartResponse.java create mode 100644 samples/slack/src/main/java/okhttp3/slack/SlackApi.java create mode 100644 samples/slack/src/main/java/okhttp3/slack/SlackClient.java diff --git a/checkstyle.xml b/checkstyle.xml index d8540c61688f..e2660531c33f 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -4,6 +4,7 @@ "http://www.puppycrawl.com/dtds/configuration_1_2.dtd"> + @@ -139,5 +140,8 @@ + + + diff --git a/pom.xml b/pom.xml index c97eaacf4d84..34c8863fb794 100644 --- a/pom.xml +++ b/pom.xml @@ -193,7 +193,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 2.10 + 2.12 true checkstyle.xml diff --git a/samples/pom.xml b/samples/pom.xml index c1c21d63c86c..1c0dc8b82985 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -18,6 +18,7 @@ guide crawler simple-client + slack static-server diff --git a/samples/slack/pom.xml b/samples/slack/pom.xml new file mode 100644 index 000000000000..2b4d58b76915 --- /dev/null +++ b/samples/slack/pom.xml @@ -0,0 +1,26 @@ + + + + 4.0.0 + + + com.squareup.okhttp3.sample + sample-parent + 3.5.0-SNAPSHOT + + + slack + Sample: Slack + + + + com.squareup.okhttp3 + mockwebserver + ${project.version} + + + com.squareup.moshi + moshi + + + diff --git a/samples/slack/src/main/java/okhttp3/slack/OAuthSession.java b/samples/slack/src/main/java/okhttp3/slack/OAuthSession.java new file mode 100644 index 000000000000..9a6869d2fbd5 --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/OAuthSession.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +/** Authorization for an application to make Slack API calls on behalf of a user. */ +@SuppressWarnings("checkstyle:membername") +public final class OAuthSession { + public final boolean ok; + public final String access_token; + public final String scope; + public final String user_id; + public final String team_name; + public final String team_id; + + public OAuthSession( + boolean ok, String accessToken, String scope, String userId, String teamName, String teamId) { + this.ok = ok; + this.access_token = accessToken; + this.scope = scope; + this.user_id = userId; + this.team_name = teamName; + this.team_id = teamId; + } + + @Override public String toString() { + return String.format("(ok=%s, access_token=%s, scope=%s, user_id=%s, team_name=%s, team_id=%s)", + ok, access_token, scope, user_id, team_name, team_id); + } +} diff --git a/samples/slack/src/main/java/okhttp3/slack/OAuthSessionFactory.java b/samples/slack/src/main/java/okhttp3/slack/OAuthSessionFactory.java new file mode 100644 index 000000000000..4b2cda21d559 --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/OAuthSessionFactory.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +import java.io.Closeable; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.LinkedHashMap; +import java.util.Map; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okio.ByteString; + +/** + * Runs a MockWebServer on localhost and uses it as the backend to receive an OAuth session. + * + *

Clients should call {@link #start}, {@link #newAuthorizeUrl} and {@link #close} in that order. + * Clients may request multiple sessions. + */ +public final class OAuthSessionFactory extends Dispatcher implements Closeable { + private final SecureRandom secureRandom = new SecureRandom(); + + private final SlackApi slackApi; + private MockWebServer mockWebServer; + + /** Guarded by this. */ + private Map listeners = new LinkedHashMap<>(); + + public OAuthSessionFactory(SlackApi slackApi) { + this.slackApi = slackApi; + } + + public void start() throws Exception { + if (mockWebServer != null) throw new IllegalStateException(); + + mockWebServer = new MockWebServer(); + mockWebServer.setDispatcher(this); + mockWebServer.start(slackApi.port); + } + + public HttpUrl newAuthorizeUrl(String scopes, String team, Listener listener) { + if (mockWebServer == null) throw new IllegalStateException(); + + ByteString state = randomToken(); + synchronized (this) { + listeners.put(state, listener); + } + + return slackApi.authorizeUrl(scopes, redirectUrl(), state, team); + } + + private ByteString randomToken() { + byte[] bytes = new byte[16]; + secureRandom.nextBytes(bytes); + return ByteString.of(bytes); + } + + private HttpUrl redirectUrl() { + return mockWebServer.url("/oauth/"); + } + + /** When the browser hits the redirect URL, use the provied code to ask Slack for a session. */ + @Override public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + HttpUrl requestUrl = mockWebServer.url(request.getPath()); + String code = requestUrl.queryParameter("code"); + String stateString = requestUrl.queryParameter("state"); + ByteString state = stateString != null ? ByteString.decodeBase64(stateString) : null; + + Listener listener; + synchronized (this) { + listener = listeners.get(state); + } + + if (code == null || listener == null) { + return new MockResponse() + .setResponseCode(404) + .setBody("unexpected request"); + } + + try { + OAuthSession session = slackApi.exchangeCode(code, redirectUrl()); + listener.sessionGranted(session); + } catch (IOException e) { + return new MockResponse() + .setResponseCode(400) + .setBody("code exchange failed: " + e.getMessage()); + } + + synchronized (this) { + listeners.remove(state); + } + + // Success! + return new MockResponse() + .setResponseCode(302) + .addHeader("Location", "https://twitter.com/CuteEmergency/status/789457462864863232"); + } + + public interface Listener { + void sessionGranted(OAuthSession session); + } + + @Override public void close() { + if (mockWebServer == null) throw new IllegalStateException(); + try { + mockWebServer.close(); + } catch (IOException ignored) { + } + } +} diff --git a/samples/slack/src/main/java/okhttp3/slack/RtmSession.java b/samples/slack/src/main/java/okhttp3/slack/RtmSession.java new file mode 100644 index 000000000000..83011d44ca88 --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/RtmSession.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +import java.io.Closeable; +import java.io.IOException; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.WebSocket; +import okhttp3.WebSocketCall; +import okhttp3.WebSocketListener; +import okio.ByteString; + +/** A realtime messaging session. */ +public final class RtmSession implements WebSocketListener, Closeable { + private final SlackApi slackApi; + private WebSocketCall webSocketCall; + + /** Guarded by this. */ + private WebSocket webSocket; + + public RtmSession(SlackApi slackApi) { + this.slackApi = slackApi; + } + + public void open(String accessToken) throws IOException { + if (webSocketCall != null) throw new IllegalStateException(); + + RtmStartResponse rtmStartResponse = slackApi.rtmStart(accessToken); + webSocketCall = slackApi.rtm(rtmStartResponse.url); + webSocketCall.enqueue(this); + } + + // TODO(jwilson): can I read the response body? Do I have to? + // the body from slack is a 0-byte-buffer + @Override public synchronized void onOpen(WebSocket webSocket, Response response) { + System.out.println("onOpen: " + response); + this.webSocket = webSocket; + } + + // TOOD(jwilson): decode incoming messages and dispatch them somewhere. + @Override public void onMessage(ResponseBody message) throws IOException { + System.out.println("onMessage: " + message.string()); + } + + @Override public void onPong(ByteString payload) { + System.out.println("onPong: " + payload); + } + + @Override public void onClose(int code, String reason) { + System.out.println("onClose (" + code + "): " + reason); + } + + // TODO(jwilson): can I read the response body? Do I have to? + @Override public void onFailure(Throwable t, Response response) { + System.out.println("onFailure " + response); + } + + @Override public void close() throws IOException { + if (webSocketCall == null) return; + + WebSocket webSocket; + synchronized (this) { + webSocket = this.webSocket; + } + + // TODO(jwilson): Racy? Is there an interleaving of events where the websocket is not closed? + // Our docs say we can’t close if we have an active writer: that seems like it + // could cause problems? + if (webSocket != null) { + webSocket.close(1000, "bye"); + } else { + webSocketCall.cancel(); + } + } +} diff --git a/samples/slack/src/main/java/okhttp3/slack/RtmStartResponse.java b/samples/slack/src/main/java/okhttp3/slack/RtmStartResponse.java new file mode 100644 index 000000000000..b19fff62560c --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/RtmStartResponse.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +import java.util.List; +import okhttp3.HttpUrl; + +/** See https://api.slack.com/methods/rtm.start. */ +public final class RtmStartResponse { + HttpUrl url; + Object self; + Object team; + List users; + List channels; + List groups; + List mpims; + List ims; + List bots; +} diff --git a/samples/slack/src/main/java/okhttp3/slack/SlackApi.java b/samples/slack/src/main/java/okhttp3/slack/SlackApi.java new file mode 100644 index 000000000000..0d0086b579c0 --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/SlackApi.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +import com.squareup.moshi.FromJson; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.ToJson; +import java.io.IOException; +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocketCall; +import okio.ByteString; + +/** + * API access to the Slack API as an application. One + * instance of this class may operate without a user, or on behalf of many users. Use the Slack API + * dashboard to create a client ID and secret for this application. + * + *

You must configure your Slack API OAuth & Permissions page with a localhost URL like {@code + * http://localhost:53203/oauth/}, passing the same port to this class’ constructor. + */ +public final class SlackApi { + private final HttpUrl baseUrl = HttpUrl.parse("https://slack.com/api/"); + private final OkHttpClient httpClient; + private final Moshi moshi; + + public final String clientId; + public final String clientSecret; + public final int port; + + public SlackApi(String clientId, String clientSecret, int port) { + this.httpClient = new OkHttpClient.Builder() + .build(); + this.moshi = new Moshi.Builder() + .add(new SlackJsonAdapters()) + .build(); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.port = port; + } + + /** See https://api.slack.com/docs/oauth. */ + public HttpUrl authorizeUrl(String scopes, HttpUrl redirectUrl, ByteString state, String team) { + HttpUrl.Builder builder = baseUrl.newBuilder("/oauth/authorize") + .addQueryParameter("client_id", clientId) + .addQueryParameter("scope", scopes) + .addQueryParameter("redirect_uri", redirectUrl.toString()) + .addQueryParameter("state", state.base64()); + + if (team != null) { + builder.addQueryParameter("team", team); + } + + return builder.build(); + } + + /** See https://api.slack.com/methods/oauth.access. */ + public OAuthSession exchangeCode(String code, HttpUrl redirectUrl) throws IOException { + HttpUrl url = baseUrl.newBuilder("oauth.access") + .addQueryParameter("client_id", clientId) + .addQueryParameter("client_secret", clientSecret) + .addQueryParameter("code", code) + .addQueryParameter("redirect_uri", redirectUrl.toString()) + .build(); + Request request = new Request.Builder() + .url(url) + .build(); + Call call = httpClient.newCall(request); + try (Response response = call.execute()) { + JsonAdapter jsonAdapter = moshi.adapter(OAuthSession.class); + return jsonAdapter.fromJson(response.body().source()); + } + } + + /** See https://api.slack.com/methods/rtm.start. */ + public RtmStartResponse rtmStart(String accessToken) throws IOException { + HttpUrl url = baseUrl.newBuilder("rtm.start") + .addQueryParameter("token", accessToken) + .build(); + Request request = new Request.Builder() + .url(url) + .build(); + Call call = httpClient.newCall(request); + try (Response response = call.execute()) { + JsonAdapter jsonAdapter = moshi.adapter(RtmStartResponse.class); + return jsonAdapter.fromJson(response.body().source()); + } + } + + /** See https://api.slack.com/rtm. */ + public WebSocketCall rtm(HttpUrl url) { + return httpClient.newWebSocketCall(new Request.Builder() + .url(url) + .build()); + } + + static final class SlackJsonAdapters { + @ToJson String urlToJson(HttpUrl httpUrl) { + return httpUrl.toString(); + } + + @FromJson HttpUrl urlFromJson(String urlString) { + if (urlString.startsWith("wss:")) urlString = "https:" + urlString.substring(4); + if (urlString.startsWith("ws:")) urlString = "http:" + urlString.substring(3); + return HttpUrl.parse(urlString); + } + } +} diff --git a/samples/slack/src/main/java/okhttp3/slack/SlackClient.java b/samples/slack/src/main/java/okhttp3/slack/SlackClient.java new file mode 100644 index 000000000000..2aa26089866a --- /dev/null +++ b/samples/slack/src/main/java/okhttp3/slack/SlackClient.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.slack; + +import java.io.IOException; +import java.io.InterruptedIOException; +import okhttp3.HttpUrl; +import okio.Timeout; + +/** A connection to Slack as a single user. */ +public final class SlackClient { + private final SlackApi slackApi; + private OAuthSessionFactory sessionFactory; + + /** Guarded by this. */ + private OAuthSession session; + + public SlackClient(SlackApi slackApi) { + this.slackApi = slackApi; + } + + /** Shows a browser URL to authorize this app to act as this user. */ + public void requestOauthSession(String scopes, String team) throws Exception { + if (sessionFactory == null) { + sessionFactory = new OAuthSessionFactory(slackApi); + sessionFactory.start(); + } + + HttpUrl authorizeUrl = sessionFactory.newAuthorizeUrl(scopes, team, + new OAuthSessionFactory.Listener() { + @Override public void sessionGranted(OAuthSession session) { + initOauthSession(session); + System.out.printf("session granted: %s\n", session); + } + }); + + System.out.printf("open this URL in a browser: %s\n", authorizeUrl); + } + + /** Set the OAuth session for this client. */ + public synchronized void initOauthSession(OAuthSession session) { + this.session = session; + this.notifyAll(); + } + + /** Waits for an OAuth session for this client to be set. */ + public synchronized void awaitAccessToken(Timeout timeout) throws InterruptedIOException { + while (session == null) { + timeout.waitUntilNotified(this); + } + } + + /** Starts a real time messaging session. */ + public void startRtm() throws IOException { + String accessToken; + synchronized (this) { + accessToken = session.access_token; + } + + RtmSession rtmSession = new RtmSession(slackApi); + rtmSession.open(accessToken); + } + + public static void main(String... args) throws Exception { + String clientId = "0000000000.00000000000"; + String clientSecret = "00000000000000000000000000000000"; + int port = 53203; + SlackApi slackApi = new SlackApi(clientId, clientSecret, port); + + SlackClient client = new SlackClient(slackApi); + String scopes = "channels:history channels:read channels:write chat:write:bot chat:write:user " + + "dnd:read dnd:write emoji:read files:read files:write:user groups:history groups:read " + + "groups:write im:history im:read im:write mpim:history mpim:read mpim:write pins:read " + + "pins:write reactions:read reactions:write search:read stars:read stars:write team:read " + + "usergroups:read usergroups:write users:read users:write identify"; + + if (true) { + client.requestOauthSession(scopes, null); + } else { + OAuthSession session = new OAuthSession(true, + "xoxp-XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + scopes, "UXXXXXXXX", "My Slack Group", "TXXXXXXXX"); + client.initOauthSession(session); + } + + client.awaitAccessToken(Timeout.NONE); + client.startRtm(); + } +}