1
0
mirror of https://github.com/square/okhttp.git synced 2026-01-25 16:01:38 +03:00

Merge pull request #225 from square/jwilson/experimental

First step in an async HTTP API.
This commit is contained in:
Jesse Wilson
2013-07-04 07:51:33 -07:00
10 changed files with 839 additions and 6 deletions

View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
class Dispatcher {
// TODO: thread pool size should be configurable; possibly configurable per host.
private final ThreadPoolExecutor executorService = new ThreadPoolExecutor(
8, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
private final Map<Object, List<Job>> enqueuedJobs = new LinkedHashMap<Object, List<Job>>();
public synchronized void enqueue(
HttpURLConnection connection, Request request, Response.Receiver responseReceiver) {
Job job = new Job(connection, request, responseReceiver);
List<Job> jobsForTag = enqueuedJobs.get(request.tag());
if (jobsForTag == null) {
jobsForTag = new ArrayList<Job>(2);
enqueuedJobs.put(request.tag(), jobsForTag);
}
jobsForTag.add(job);
executorService.execute(job);
}
public synchronized void cancel(Object tag) {
List<Job> jobs = enqueuedJobs.remove(tag);
if (jobs == null) return;
for (Job job : jobs) {
executorService.remove(job);
}
}
private synchronized void finished(Job job) {
List<Job> jobs = enqueuedJobs.get(job.request.tag());
if (jobs != null) jobs.remove(job);
}
public class Job implements Runnable {
private final HttpURLConnection connection;
private final Request request;
private final Response.Receiver responseReceiver;
public Job(HttpURLConnection connection, Request request, Response.Receiver responseReceiver) {
this.connection = connection;
this.request = request;
this.responseReceiver = responseReceiver;
}
@Override public void run() {
try {
sendRequest();
Response response = readResponse();
responseReceiver.onResponse(response);
} catch (IOException e) {
responseReceiver.onFailure(new Failure.Builder()
.request(request)
.exception(e)
.build());
} finally {
connection.disconnect();
finished(this);
}
}
private HttpURLConnection sendRequest()
throws IOException {
for (int i = 0; i < request.headerCount(); i++) {
connection.addRequestProperty(request.headerName(i), request.headerValue(i));
}
Request.Body body = request.body();
if (body != null) {
connection.setDoOutput(true);
body.writeTo(connection.getOutputStream());
}
return connection;
}
private Response readResponse() throws IOException {
int responseCode = connection.getResponseCode();
Response.Builder responseBuilder = new Response.Builder(request, responseCode);
for (int i = 0; true; i++) {
String name = connection.getHeaderFieldKey(i);
if (name == null) break;
String value = connection.getHeaderField(i);
responseBuilder.addHeader(name, value);
}
responseBuilder.body(new RealResponseBody(connection, connection.getInputStream()));
// TODO: set redirectedBy
return responseBuilder.build();
}
}
static class RealResponseBody extends Response.Body {
private final HttpURLConnection connection;
private final InputStream in;
RealResponseBody(HttpURLConnection connection, InputStream in) {
this.connection = connection;
this.in = in;
}
@Override public String contentType() {
return connection.getHeaderField("Content-Type");
}
@Override public long contentLength() {
return connection.getContentLength(); // TODO: getContentLengthLong
}
@Override public InputStream byteStream() throws IOException {
return in;
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp;
/**
* A failure attempting to retrieve an HTTP response.
*
* <h3>Warning: Experimental OkHttp 2.0 API</h3>
* This class is in beta. APIs are subject to change!
*/
public class Failure {
private final Request request;
private final Throwable exception;
private Failure(Builder builder) {
this.request = builder.request;
this.exception = builder.exception;
}
public Request request() {
return request;
}
public Throwable exception() {
return exception;
}
public static class Builder {
private Request request;
private Throwable exception;
public Builder request(Request request) {
this.request = request;
return this;
}
public Builder exception(Throwable exception) {
this.exception = exception;
return this;
}
public Failure build() {
return new Failure(this);
}
}
}

View File

@@ -59,6 +59,7 @@ public final class OkHttpClient implements URLStreamHandlerFactory {
private boolean followProtocolRedirects = true;
private int connectTimeout;
private int readTimeout;
private Dispatcher dispatcher = new Dispatcher();
public OkHttpClient() {
this.failedRoutes = Collections.synchronizedSet(new LinkedHashSet<Route>());
@@ -311,6 +312,24 @@ public final class OkHttpClient implements URLStreamHandlerFactory {
return transports;
}
/**
* Schedules {@code request} to be executed.
*/
public void enqueue(Request request, Response.Receiver responseReceiver) {
// Create the HttpURLConnection immediately so the enqueued job gets the current settings of
// this client. Otherwise changes to this client (socket factory, redirect policy, etc.) may
// incorrectly be reflected in the request when it is dispatched later.
dispatcher.enqueue(open(request.url()), request, responseReceiver);
}
/**
* Cancels all scheduled tasks tagged with {@code tag}. Requests that are already
* in flight might not be canceled.
*/
public void cancel(Object tag) {
dispatcher.cancel(tag);
}
public HttpURLConnection open(URL url) {
String protocol = url.getProtocol();
OkHttpClient copy = copyWithDefaults();

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp;
import com.squareup.okhttp.internal.http.RawHeaders;
import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Set;
/**
* An HTTP request. Instances of this class are immutable if their {@link #body}
* is null or itself immutable.
*
* <h3>Warning: Experimental OkHttp 2.0 API</h3>
* This class is in beta. APIs are subject to change!
*/
public final class Request {
private final URL url;
private final String method;
private final RawHeaders headers;
private final Body body;
private final Object tag;
private Request(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = new RawHeaders(builder.headers);
this.body = builder.body;
this.tag = builder.tag != null ? builder.tag : this;
}
public URL url() {
return url;
}
public String urlString() {
return url.toString();
}
public String method() {
return method;
}
public String header(String name) {
return headers.get(name);
}
public List<String> headers(String name) {
return headers.values(name);
}
public Set<String> headerNames() {
return headers.names();
}
public int headerCount() {
return headers.length();
}
public String headerName(int index) {
return headers.getFieldName(index);
}
public String headerValue(int index) {
return headers.getValue(index);
}
public Body body() {
return body;
}
public Object tag() {
return tag;
}
public abstract class Body {
/** Returns the Content-Type header for this body, or null if the content type is unknown. */
public String contentType() {
return null;
}
/** Returns the number of bytes in this body, or -1 if that count is unknown. */
public long contentLength() {
return -1;
}
public abstract void writeTo(OutputStream out) throws IOException;
}
public static class Builder {
private URL url;
private String method = "GET";
private final RawHeaders headers = new RawHeaders();
private Body body;
private Object tag;
public Builder(String url) {
url(url);
}
public Builder(URL url) {
url(url);
}
public Builder url(String url) {
try {
this.url = new URL(url);
return this;
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Malformed URL: " + url);
}
}
public Builder url(URL url) {
if (url == null) throw new IllegalStateException("url == null");
this.url = url;
return this;
}
/**
* Sets the header named {@code name} to {@code value}. If this request
* already has any headers with that name, they are all replaced.
*/
public Builder header(String name, String value) {
headers.set(name, value);
return this;
}
/**
* Adds a header with {@code name} and {@code value}. Prefer this method for
* multiply-valued headers like "Cookie".
*/
public Builder addHeader(String name, String value) {
headers.add(name, value);
return this;
}
public Builder get() {
return method("GET", null);
}
public Builder head() {
return method("HEAD", null);
}
public Builder post(Body body) {
return method("POST", body);
}
public Builder put(Body body) {
return method("PUT", body);
}
public Builder method(String method, Body body) {
if (method == null || method.length() == 0) {
throw new IllegalArgumentException("method == null || method.length() == 0");
}
this.method = method;
this.body = body;
return this;
}
/**
* Attaches {@code tag} to the request. It can be used later to cancel the
* request. If the tag is unspecified or null, the request is canceled by
* using the request itself as the tag.
*/
public Builder tag(Object tag) {
this.tag = tag;
return this;
}
public Request build() {
return new Request(this);
}
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp;
import com.squareup.okhttp.internal.Util;
import com.squareup.okhttp.internal.http.RawHeaders;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.List;
import java.util.Set;
/**
* An HTTP response. Instances of this class are not immutable: the response
* body is a one-shot value that may be consumed only once. All other properties
* are immutable.
*
* <h3>Warning: Experimental OkHttp 2.0 API</h3>
* This class is in beta. APIs are subject to change!
*/
public final class Response {
private final Request request;
private final int code;
private final RawHeaders headers;
private final Body body;
private final Response redirectedBy;
private Response(Builder builder) {
this.request = builder.request;
this.code = builder.code;
this.headers = new RawHeaders(builder.headers);
this.body = builder.body;
this.redirectedBy = builder.redirectedBy;
}
/**
* The wire-level request that initiated this HTTP response. This is usually
* <strong>not</strong> the same request instance provided to the HTTP client:
* <ul>
* <li>It may be transformed by the HTTP client. For example, the client
* may have added its own {@code Content-Encoding} header to enable
* response compression.
* <li>It may be the request generated in response to an HTTP redirect.
* In this case the request URL may be different than the initial
* request URL.
* </ul>
*/
public Request request() {
return request;
}
public int code() {
return code;
}
public String header(String name) {
return header(name, null);
}
public String header(String name, String defaultValue) {
String result = headers.get(name);
return result != null ? result : defaultValue;
}
public List<String> headers(String name) {
return headers.values(name);
}
public Set<String> headerNames() {
return headers.names();
}
public int headerCount() {
return headers.length();
}
public String headerName(int index) {
return headers.getFieldName(index);
}
public String headerValue(int index) {
return headers.getValue(index);
}
public Body body() {
return body;
}
/**
* Returns the response for the HTTP redirect that triggered this response, or
* null if this response wasn't triggered by an automatic redirect. The body
* of the returned response should not be read because it has already been
* consumed by the redirecting client.
*/
public Response redirectedBy() {
return redirectedBy;
}
public abstract static class Body {
public String contentType() {
return null;
}
public long contentLength() {
return -1;
}
public abstract InputStream byteStream() throws IOException;
public byte[] bytes() throws IOException {
long contentLength = contentLength();
if (contentLength > Integer.MAX_VALUE) {
throw new IOException("Cannot buffer entire body for content length: " + contentLength);
}
if (contentLength != -1) {
byte[] content = new byte[(int) contentLength];
InputStream in = byteStream();
Util.readFully(in, content);
if (in.read() != -1) throw new IOException("Content-Length and stream length disagree");
return content;
} else {
ByteArrayOutputStream out = new ByteArrayOutputStream();
Util.copy(byteStream(), out);
return out.toByteArray();
}
}
/**
* Returns the response bytes as a UTF-8 character stream. Do not call this
* method if the response content is not a UTF-8 character stream.
*/
public Reader charStream() throws IOException {
// TODO: parse content-type.
return new InputStreamReader(byteStream(), "UTF-8");
}
/**
* Returns the response bytes as a UTF-8 string. Do not call this method if
* the response content is not a UTF-8 character stream.
*/
public String string() throws IOException {
// TODO: parse content-type.
return new String(bytes(), "UTF-8");
}
}
public interface Receiver {
void onFailure(Failure failure);
void onResponse(Response response) throws IOException;
}
public static class Builder {
private final Request request;
private final int code;
private final RawHeaders headers = new RawHeaders();
private Body body;
private Response redirectedBy;
public Builder(Request request, int code) {
if (request == null) throw new IllegalArgumentException("request == null");
if (code <= 0) throw new IllegalArgumentException("code <= 0");
this.request = request;
this.code = code;
}
/**
* Sets the header named {@code name} to {@code value}. If this request
* already has any headers with that name, they are all replaced.
*/
public Builder header(String name, String value) {
headers.set(name, value);
return this;
}
/**
* Adds a header with {@code name} and {@code value}. Prefer this method for
* multiply-valued headers like "Set-Cookie".
*/
public Builder addHeader(String name, String value) {
headers.add(name, value);
return this;
}
public Builder body(Body body) {
this.body = body;
return this;
}
public Builder redirectedBy(Response redirectedBy) {
this.redirectedBy = redirectedBy;
return this;
}
public Response build() {
if (request == null) throw new IllegalStateException("Response has no request.");
if (code == -1) throw new IllegalStateException("Response has no code.");
return new Response(this);
}
}
}

View File

@@ -606,8 +606,8 @@ public class HttpURLConnectionImpl extends HttpURLConnection {
// the list contains "http/1.1". We do this in a separate loop
// to avoid modifying any state before we validate the input.
boolean containsHttp = false;
for (int i = 0; i < transports.length; ++i) {
if ("http/1.1".equals(transports[i])) {
for (String transport : transports) {
if ("http/1.1".equals(transport)) {
containsHttp = true;
break;
}
@@ -620,13 +620,13 @@ public class HttpURLConnectionImpl extends HttpURLConnection {
transportsList.addAll(this.transports);
}
for (int i = 0; i < transports.length; ++i) {
if (transports[i].length() == 0) {
for (String transport : transports) {
if (transport.length() == 0) {
throw new IllegalArgumentException("Transport list contains an empty transport");
}
if (!transportsList.contains(transports[i])) {
transportsList.add(transports[i]);
if (!transportsList.contains(transport)) {
transportsList.add(transport);
}
}

View File

@@ -32,6 +32,7 @@ import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* The HTTP status and unparsed header fields of a single HTTP message. Values
@@ -248,6 +249,15 @@ public final class RawHeaders {
return namesAndValues.get(fieldNameIndex);
}
/** Returns an immutable case-insensitive set of header names. */
public Set<String> names() {
TreeSet<String> result = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
for (int i = 0; i < length(); i++) {
result.add(getFieldName(i));
}
return Collections.unmodifiableSet(result);
}
/** Returns the value at {@code index} or null if that is out of range. */
public String getValue(int index) {
int valueIndex = index * 2 + 1;
@@ -267,6 +277,20 @@ public final class RawHeaders {
return null;
}
/** Returns an immutable list of the header values for {@code name}. */
public List<String> values(String name) {
List<String> result = null;
for (int i = 0; i < length(); i++) {
if (name.equalsIgnoreCase(getFieldName(i))) {
if (result == null) result = new ArrayList<String>(2);
result.add(getValue(i));
}
}
return result != null
? Collections.unmodifiableList(result)
: Collections.<String>emptyList();
}
/** @param fieldNames a case-insensitive set of HTTP header field names. */
public RawHeaders getAll(Set<String> fieldNames) {
RawHeaders result = new RawHeaders();

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp.internal;
import com.google.mockwebserver.MockResponse;
import com.google.mockwebserver.MockWebServer;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import org.junit.After;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
public final class AsyncApiTest {
private MockWebServer server = new MockWebServer();
private OkHttpClient client = new OkHttpClient();
private RecordingReceiver receiver = new RecordingReceiver();
@After public void tearDown() throws Exception {
server.shutdown();
}
@Test public void get() throws Exception {
server.enqueue(new MockResponse()
.setBody("abc")
.addHeader("Content-Type: text/plain"));
server.play();
Request request = new Request.Builder(server.getUrl("/"))
.header("User-Agent", "AsyncApiTest")
.build();
client.enqueue(request, receiver);
receiver.await(request)
.assertCode(200)
.assertContainsHeaders("Content-Type: text/plain")
.assertBody("abc");
assertTrue(server.takeRequest().getHeaders().contains("User-Agent: AsyncApiTest"));
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp.internal;
import com.squareup.okhttp.Failure;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.Response;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
/**
* A received response or failure recorded by the response recorder.
*/
public class RecordedResponse {
public final Request request;
public final Response response;
public final String body;
public final Failure failure;
RecordedResponse(Request request, Response response, String body, Failure failure) {
this.request = request;
this.response = response;
this.body = body;
this.failure = failure;
}
public RecordedResponse assertCode(int expectedCode) {
assertEquals(expectedCode, response.code());
return this;
}
public RecordedResponse assertContainsHeaders(String... expectedHeaders) {
List<String> actualHeaders = new ArrayList<String>();
for (int i = 0; i < response.headerCount(); i++) {
actualHeaders.add(response.headerName(i) + ": " + response.headerValue(i));
}
if (!actualHeaders.containsAll(Arrays.asList(expectedHeaders))) {
fail("Expected: " + actualHeaders + "\nto contain: " + Arrays.toString(expectedHeaders));
}
return this;
}
public RecordedResponse assertBody(String expectedBody) {
assertEquals(expectedBody, body);
return this;
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2013 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 com.squareup.okhttp.internal;
import com.squareup.okhttp.Failure;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Records received HTTP responses so they can be later retrieved by tests.
*/
public class RecordingReceiver implements Response.Receiver {
public static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
private final List<RecordedResponse> responses = new ArrayList<RecordedResponse>();
@Override public synchronized void onFailure(Failure failure) {
responses.add(new RecordedResponse(failure.request(), null, null, failure));
notifyAll();
}
@Override public synchronized void onResponse(Response response) throws IOException {
responses.add(new RecordedResponse(
response.request(), response, response.body().string(), null));
notifyAll();
}
/**
* Returns the recorded response triggered by {@code request}. Throws if the
* response isn't enqueued before the timeout.
*/
public synchronized RecordedResponse await(Request request) throws Exception {
long timeoutMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + TIMEOUT_MILLIS;
while (true) {
for (RecordedResponse recordedResponse : responses) {
if (recordedResponse.request == request) {
return recordedResponse;
}
}
long nowMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
if (nowMillis >= timeoutMillis) break;
wait(timeoutMillis - nowMillis);
}
throw new AssertionError("Timed out waiting for response to " + request);
}
}