Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fixes #468: Dialogue clients send Accept headers #482

Merged
merged 4 commits into from
Mar 4, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .palantir/revapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,10 @@ acceptedBreaks:
old: "method java.util.Optional<java.lang.String> com.palantir.dialogue.PlainSerDe::serializeOptionalUuid(java.util.Optional<java.util.UUID>)"
new: null
justification: "unused methods"
"0.13.0":
com.palantir.dialogue:dialogue-blocking-channels: []
com.palantir.dialogue:dialogue-target:
- code: "java.method.addedToInterface"
old: null
new: "method com.palantir.dialogue.Deserializer<java.io.InputStream> com.palantir.dialogue.BodySerDe::inputStreamDeserializer()"
justification: "fixin' apis"
5 changes: 5 additions & 0 deletions changelog/@unreleased/pr-482.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: improvement
improvement:
description: Dialogue clients send Accept headers
links:
- https://github.com/palantir/dialogue/pull/482
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* (c) Copyright 2020 Palantir Technologies Inc. All rights reserved.
*
* 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.palantir.conjure.java.dialogue.serde;

import com.palantir.dialogue.TypeMarker;
import com.palantir.logsafe.Preconditions;
import com.palantir.logsafe.SafeArg;
import java.io.InputStream;

/**
* Package-private internal api.
* This partial Encoding implementation exists to allow binary responses to share the same safety
* and validation provided by structured encodings. This is only consumed internally to create
* a binary-specific <pre>EncodingDeserializerRegistry</pre>.
*/
enum BinaryEncoding implements Encoding {
INSTANCE;

static final String CONTENT_TYPE = "application/octet-stream";
static final TypeMarker<InputStream> MARKER = new TypeMarker<InputStream>() {};

@Override
public <T> Serializer<T> serializer(TypeMarker<T> _type) {
throw new UnsupportedOperationException("BinaryEncoding does not support serializers");
}

@Override
@SuppressWarnings("unchecked")
public <T> Deserializer<T> deserializer(TypeMarker<T> type) {
Preconditions.checkArgument(
InputStream.class.equals(type.getType()),
"BinaryEncoding only supports InputStream",
SafeArg.of("requested", type));
return input -> (T) input;
}

@Override
public String getContentType() {
return CONTENT_TYPE;
}

@Override
public boolean supportsContentType(String contentType) {
return Encodings.matchesContentType(CONTENT_TYPE, contentType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@
import java.io.OutputStream;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.ws.rs.core.HttpHeaders;

/** Package private internal API. */
final class ConjureBodySerDe implements BodySerDe {
private static final String BINARY_CONTENT_TYPE = "application/octet-stream";

private final List<Encoding> encodings;
private final ErrorDecoder errorDecoder;
private final Encoding defaultEncoding;
private final Deserializer<InputStream> binaryInputStreamDeserializer;

/**
* Selects the first (based on input order) of the provided encodings that
Expand All @@ -61,6 +62,8 @@ final class ConjureBodySerDe implements BodySerDe {
this.errorDecoder = errorDecoder;
Preconditions.checkArgument(encodings.size() > 0, "At least one Encoding is required");
this.defaultEncoding = encodings.get(0);
this.binaryInputStreamDeserializer = new EncodingDeserializerRegistry<>(
ImmutableList.of(BinaryEncoding.INSTANCE), errorDecoder, BinaryEncoding.MARKER);
}

@Override
Expand All @@ -74,13 +77,13 @@ public <T> Deserializer<T> deserializer(TypeMarker<T> token) {
}

@Override
@SuppressWarnings("NullAway") // empty body is a special case
public Deserializer<Void> emptyBodyDeserializer() {
return input -> {
// We should not fail if a server that previously returned nothing starts returning a response
input.close();
return null;
};
return EmptyBodyDeserializer.INSTANCE;
}

@Override
public Deserializer<InputStream> inputStreamDeserializer() {
return binaryInputStreamDeserializer;
}

@Override
Expand All @@ -95,23 +98,11 @@ public void writeTo(OutputStream output) throws IOException {

@Override
public String contentType() {
return BINARY_CONTENT_TYPE;
return BinaryEncoding.CONTENT_TYPE;
}
};
}

@Override
public InputStream deserializeInputStream(Response exchange) {
Optional<String> contentType = exchange.getFirstHeader(HttpHeaders.CONTENT_TYPE);
if (!contentType.isPresent()) {
throw new SafeIllegalArgumentException("Response is missing Content-Type header");
}
if (!contentType.get().startsWith(BINARY_CONTENT_TYPE)) {
throw new SafeIllegalArgumentException("Unsupported Content-Type", SafeArg.of("Content-Type", contentType));
}
return exchange.body();
}

private static final class EncodingSerializerRegistry<T> implements Serializer<T> {

private final EncodingSerializerContainer<T> encoding;
Expand Down Expand Up @@ -154,12 +145,16 @@ private static final class EncodingDeserializerRegistry<T> implements Deserializ

private final ImmutableList<EncodingDeserializerContainer<T>> encodings;
private final ErrorDecoder errorDecoder;
private final Optional<String> acceptValue;

EncodingDeserializerRegistry(List<Encoding> encodings, ErrorDecoder errorDecoder, TypeMarker<T> token) {
this.encodings = encodings.stream()
.map(encoding -> new EncodingDeserializerContainer<>(encoding, token))
.collect(ImmutableList.toImmutableList());
this.errorDecoder = errorDecoder;
// Encodings are applied to the accept header in the order of preference based on the provided list.
this.acceptValue =
Optional.of(encodings.stream().map(Encoding::getContentType).collect(Collectors.joining(", ")));
}

@Override
Expand All @@ -177,6 +172,11 @@ public T deserialize(Response response) {
}
}

@Override
public Optional<String> accepts() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of joining the separate accept headers into a single string should we expose a List<String>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this initially, but found that it maps to adding multiple Accept headers each with a single value, but it's most common to use Accept header with multiple values. The optional approach allows us to pre-calculate the value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems surprising to encode multiple accept values as a header value since in a multimap set up I would expect each value in the map to be a single header value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely agree that multiple accept header key/value pairs would be clean, but I don't think I've seen it used that way in practice. I think we'll reduce odd edge case failures by following the principle of least astonishment.

return acceptValue;
}

/** Returns the {@link EncodingDeserializerContainer} to use to deserialize the request body. */
@SuppressWarnings("ForLoopReplaceableByForEach")
// performance sensitive code avoids iterator allocation
Expand Down Expand Up @@ -204,4 +204,21 @@ private static final class EncodingDeserializerContainer<T> {
this.deserializer = encoding.deserializer(token);
}
}

private enum EmptyBodyDeserializer implements Deserializer<Void> {
INSTANCE;

@Override
@SuppressWarnings("NullAway") // empty body is a special case
public Void deserialize(Response response) {
// We should not fail if a server that previously returned nothing starts returning a response
response.close();
return null;
}

@Override
public Optional<String> accepts() {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.palantir.conjure.java.dialogue.serde;

import com.google.common.net.HttpHeaders;
import com.google.common.util.concurrent.ExecutionError;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
Expand All @@ -29,10 +30,13 @@
import com.palantir.dialogue.Deserializer;
import com.palantir.dialogue.Endpoint;
import com.palantir.dialogue.Request;
import com.palantir.logsafe.Preconditions;
import com.palantir.logsafe.SafeArg;
import com.palantir.logsafe.UnsafeArg;
import com.palantir.logsafe.exceptions.SafeRuntimeException;
import java.io.Closeable;
import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
Expand All @@ -47,8 +51,10 @@ enum DefaultClients implements Clients {
@Override
public <T> ListenableFuture<T> call(
Channel channel, Endpoint endpoint, Request request, Deserializer<T> deserializer) {
Optional<String> accepts = deserializer.accepts();
Request outgoingRequest = accepts.isPresent() ? accepting(request, accepts.get()) : request;
return Futures.transform(
channel.execute(endpoint, request), deserializer::deserialize, MoreExecutors.directExecutor());
channel.execute(endpoint, outgoingRequest), deserializer::deserialize, MoreExecutors.directExecutor());
}

@Override
Expand Down Expand Up @@ -120,4 +126,20 @@ public void onFailure(Throwable throwable) {
log.info("Canceled call failed", throwable);
}
}

private static Request accepting(Request original, String acceptValue) {
Preconditions.checkNotNull(acceptValue, "Accept value is required");
Preconditions.checkState(!acceptValue.isEmpty(), "Accept value must not be empty");
if (original.headerParams().containsKey(HttpHeaders.ACCEPT)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we append the acceptValue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, we'd need to deduplicate repeated accept values, and the existing values will be preferred since we're using a list, which isn't what we want here. I considered throwing instead of logging but it's difficult to trace where the value was added (unlike the null/empty inputs) so we would want to return a future.

log.warn(
"Request {} already contains an Accept header value {}",
UnsafeArg.of("request", original),
SafeArg.of("existingAcceptValue", original.headerParams().get(HttpHeaders.ACCEPT)));
return original;
}
return Request.builder()
.from(original)
.putHeaderParams(HttpHeaders.ACCEPT, acceptValue)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,26 @@
package com.palantir.conjure.java.dialogue.serde;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.google.common.net.HttpHeaders;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.palantir.dialogue.Channel;
import com.palantir.dialogue.Deserializer;
import com.palantir.dialogue.Endpoint;
import com.palantir.dialogue.Request;
import com.palantir.dialogue.Response;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

Expand All @@ -48,6 +55,9 @@ public final class DefaultClientsTest {
@Mock
private Deserializer<String> deserializer;

@Captor
private ArgumentCaptor<Request> requestCaptor;

@Test
public void testCall() throws ExecutionException, InterruptedException {
Request request = Request.builder().build();
Expand All @@ -60,4 +70,19 @@ public void testCall() throws ExecutionException, InterruptedException {
assertThat(result).isDone();
assertThat(result.get()).isEqualTo("value");
}

@Test
public void testAddsAcceptHeader() throws ExecutionException, InterruptedException {
Request request = Request.builder().build();
when(deserializer.deserialize(eq(response))).thenReturn("value");
String expectedAccept = "application/json";
when(deserializer.accepts()).thenReturn(Optional.of(expectedAccept));
when(channel.execute(eq(endpoint), any())).thenReturn(Futures.immediateFuture(response));
ListenableFuture<String> result = DefaultClients.INSTANCE.call(channel, endpoint, request, deserializer);
assertThat(result).isDone();
assertThat(result.get()).isEqualTo("value");
verify(channel).execute(eq(endpoint), requestCaptor.capture());
assertThat(requestCaptor.getValue().headerParams().get(HttpHeaders.ACCEPT))
.containsExactly(expectedAccept);
carterkozak marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ public interface BodySerDe {
*/
Deserializer<Void> emptyBodyDeserializer();

/**
* Returns a {@link Deserializer} that reads an {@link InputStream} from the {@link Response} body.
* <p>
* This method is named <pre>inputStreamDeserializer</pre> not <pre>binaryDeserializer</pre>
* to support future streaming binary bindings without conflicting method signatures.
*/
Deserializer<InputStream> inputStreamDeserializer();

/** Serializes a {@link BinaryRequestBody} to <pre>application/octet-stream</pre>. */
RequestBody serialize(BinaryRequestBody value);

Expand All @@ -40,6 +48,11 @@ public interface BodySerDe {
* <p>
* This method is named <pre>deserializeInputStream</pre> not <pre>deserializeBinary</pre>
* to support future streaming binary bindings without conflicting method signatures.
*
* @deprecated Prefer {@link #inputStreamDeserializer()}
*/
InputStream deserializeInputStream(Response response);
@Deprecated
default InputStream deserializeInputStream(Response response) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just get rid of this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our circular dependency on generated code would break

return inputStreamDeserializer().deserialize(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,22 @@

package com.palantir.dialogue;

import java.util.Optional;

/** Reads objects from a response. */
public interface Deserializer<T> {

/** Deserializes the response body. */
T deserialize(Response response);

/**
* Returns the content types this deserializer accepts, if any.
* Values are structured for the <pre>Accept</pre> header based
* on <a href="https://tools.ietf.org/html/rfc7231#section-5.3.2">rfc 7231 section 5.3.2</a>.
*/
default Optional<String> accepts() {
// TODO(ckozak): This should not be a functional interface. This method is default
// only to allow the generator to be updated to avoid deserializer method references.
return Optional.empty();
}
}