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

Auth Manager API part 1: HTTPRequest, HTTPHeader #11769

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

adutra
Copy link
Contributor

@adutra adutra commented Dec 12, 2024

As requested, I'm splitting #10753 in many PRs. This one is the first one. It introduces HTTPRequest which is a prerequisite for the AuthManager API.

@github-actions github-actions bot added the core label Dec 12, 2024

/** Returns the headers of this request. */
@Value.Default
default HTTPHeaders headers() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@danielcweeks @nastra FYI, I wasn't satisfied with returning Map<String, List<String>> from here, especially because of corner cases around case-sensitivity. I was planning to tackle that as a follow-up task, but since we are starting from square zero here, what you see here is the "enhanced" version.

@adutra adutra mentioned this pull request Dec 12, 2024
* values. Header names are case-insensitive.
*/
@Value.Lazy
default Map<String, List<String>> asMap() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these methods will be useful later.

@Value.Style(depluralize = true)
@Value.Immutable
@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"})
public interface HTTPHeaders {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This class is loosely inspired by org.apache.hc.core5.http.message.HeaderGroup.

*/
@Value.Lazy
default URI requestUri() {
return RESTUtil.buildRequestUri(this);
Copy link
Contributor

Choose a reason for hiding this comment

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

to me it seems like we should rather embed the functionality of building an URI from a HTTPRequest here (and also do the same for encoding the body). Additionally, building an URI from the path does some validation to make sure it doesn't start with a /. I'd say this is something that we can move into a check method annotated with @Value.Check. That way we don't need the util methods in RESTUtil and can move testing into TestHTTPRequest. I've added an example diff below:

--- a/core/src/main/java/org/apache/iceberg/rest/HTTPRequest.java
+++ b/core/src/main/java/org/apache/iceberg/rest/HTTPRequest.java
@@ -18,10 +18,14 @@
  */
 package org.apache.iceberg.rest;

+import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.Map;
 import javax.annotation.Nullable;
+import org.apache.hc.core5.net.URIBuilder;
+import org.apache.iceberg.exceptions.RESTException;
 import org.immutables.value.Value;

 /** Represents an HTTP request. */
@@ -49,7 +53,19 @@ public interface HTTPRequest {
    */
   @Value.Lazy
   default URI requestUri() {
-    return RESTUtil.buildRequestUri(this);
+    // if full path is provided, use the input path as path
+    String fullPath =
+        (path().startsWith("https://") || path().startsWith("http://"))
+            ? path()
+            : String.format("%s/%s", baseUri(), path());
+    try {
+      URIBuilder builder = new URIBuilder(RESTUtil.stripTrailingSlash(fullPath));
+      queryParameters().forEach(builder::addParameter);
+      return builder.build();
+    } catch (URISyntaxException e) {
+      throw new RESTException(
+          "Failed to create request URI from base %s, params %s", fullPath, queryParameters());
+    }
   }

   /** Returns the HTTP method of this request. */
@@ -77,7 +93,17 @@ public interface HTTPRequest {
   @Nullable
   @Value.Redacted
   default String encodedBody() {
-    return RESTUtil.encodeRequestBody(this);
+    Object body = body();
+    if (body instanceof Map) {
+      return RESTUtil.encodeFormData((Map<?, ?>) body);
+    } else if (body != null) {
+      try {
+        return mapper().writeValueAsString(body);
+      } catch (JsonProcessingException e) {
+        throw new RESTException(e, "Failed to encode request body: %s", body);
+      }
+    }
+    return null;
   }

   /**
@@ -88,4 +114,13 @@ public interface HTTPRequest {
   default ObjectMapper mapper() {
     return RESTObjectMapper.mapper();
   }
+
+  @Value.Check
+  default void check() {
+    if (path().startsWith("/")) {
+      throw new RESTException(
+          "Received a malformed path for a REST request: %s. Paths should not start with /",
+          path());
+    }
+  }
 }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes that's a good idea.

* case-insensitive.
*/
@Value.Lazy
default ListMultimap<String, String> asMultiMap() {
Copy link
Contributor

Choose a reason for hiding this comment

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

do we actually have a use case for this? I see the value in having asMap() / asSimpleMap() but I feel like we don't need this method atm?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This one will be very useful later to do things like this in HTTPClient:

HTTPRequest req = ...
HttpUriRequestBase request = new HttpUriRequestBase(req.method().name(), req.requestUri());
req.headers().asMultiMap().forEach(request::addHeader);

return builder.build();
}

static HTTPHeaders fromMultiMap(Multimap<String, String> headers) {
Copy link
Contributor

Choose a reason for hiding this comment

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

same as I mentioned above. Do we actually have a use case for a bunch of these static methods? I would try to keep those to the amount we actually need

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the methods are tested, is that a big deal? This one indeed has no usage today, but more auth managers in the future could use it.

Copy link
Contributor

Choose a reason for hiding this comment

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

The Iceberg project typically tries to only add things to APIs that are needed for current use cases instead of planning for potential use cases that may or may not actually exist/happen (independent of whether there are tests for unused methods or not).
You also have to keep in mind that everything that's being added to iceberg-core can't be easily changed without going through an API deprecation cycle, so we're trying to be mindful about what's really required in terms of APIs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair enough, let's remove all the static methods for now.


final HTTPHeaders headers =
HTTPHeaders.of(
HTTPHeader.of("header1", "value1a"),
Copy link
Contributor

Choose a reason for hiding this comment

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

please also add a test where the key/value is null


class TestHTTPHeaders {

final HTTPHeaders headers =
Copy link
Contributor

Choose a reason for hiding this comment

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

can be private


@Test
void entries() {
assertThat(headers.entries("header1"))
Copy link
Contributor

Choose a reason for hiding this comment

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

please also add tests where null is passed to all the methods that take nullable parameters, such as entries()/contains()/addIfAbsent()

@nastra nastra requested a review from danielcweeks December 13, 2024 09:01
@@ -95,6 +100,9 @@ void addIfAbsentHTTPHeader() {
"header1", List.of("value1a", "value1b"),
"header2", List.of("value2"),
"header3", List.of("value3")));

assertThatThrownBy(() -> headers.addIfAbsent((HTTPHeaders) null))
.isInstanceOf(NullPointerException.class);
Copy link
Contributor

Choose a reason for hiding this comment

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

checks like this should always have a .hasMessage check to make sure we get the right error msg back (unfortunately we can't easily enforce such a rule via checkstyle)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The message is generated by Immutables and will be rather cryptic. But OK.

* Returns a new instance with the added header, or the current instance if the header is already
* present.
*/
default HTTPHeaders addIfAbsent(HTTPHeader header) {
Copy link
Contributor

Choose a reason for hiding this comment

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

we might want to consider naming this (and the other method) withHeaderIfAbsent() to clearly indicate that (potentially) a new instance is returned because add in the name suggests that the existing object is modified

@ParameterizedTest
@MethodSource("validRequestBodies")
public void encodedBody(HTTPRequest request, String expected) {
assertThat(request.encodedBody()).isEqualTo(expected);
Copy link
Contributor

Choose a reason for hiding this comment

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

personally I think the test code would be much more readable without having a parameterized test and rather have just two separate test methods that verify the body is properly encoded as form data/JSON, but I'm ok with what's currently here and I'll leave that up to you.

Also we probably might want to have some tests where the body can't be encoded due to:

  • body is null
  • body is a Map with null values
  • body is an invalid JSON

Copy link
Contributor

@nastra nastra left a comment

Choose a reason for hiding this comment

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

LGTM, thanks @adutra for all your work on this.

HTTPHeaders EMPTY = of();

/** Returns all the header entries in this group. */
List<HTTPHeader> entries();
Copy link
Contributor

@danielcweeks danielcweeks Dec 16, 2024

Choose a reason for hiding this comment

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

Shouldn't this be Set<HTTPHeader>? This would also take care of cases where there are actualy k/v duplicates (which we don't appear to handle)

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like we should also annotate this as @NotNull

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 see a @NotNull annotation available in iceberg-core? Besides, the immutable generated class is annotated with @AllParametersAreNonNullByDefault.

Copy link
Contributor

Choose a reason for hiding this comment

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

@danielcweeks @NotNull shouldn't be needed here, because everything not marked as @Nullable or Optional is implicitly not null with Immutables

List<HTTPHeader> entries();

/** Returns all the entries in this group for the given name (case-insensitive). */
default List<HTTPHeader> entries(String name) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, set

* Returns a new instance with the added headers, or the current instance if all headers are
* already present.
*/
default HTTPHeaders withHeaderIfAbsent(HTTPHeaders headers) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like putIfAbsent() is more idiomatic here. Otherwise it just code would generally be redundant:

headers.putHeaderIfAbsent(header)

vs.

headers.putIfAbsent(header)

* Returns a new instance with the added header, or the current instance if the header is already
* present.
*/
default HTTPHeaders withHeaderIfAbsent(HTTPHeader header) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same idiomatic comment here. Generally we use with prefix for builders, not mutators.

}

/** Returns whether this group contains an entry with the given name (case-insensitive). */
default boolean contains(String name) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there are reason we're making this overridable? It seems like we should insulate the case behavior by making this a private static method.


String name();

@Value.Redacted
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this mean that we redact all header values? Maybe I don't fully understand the Immutable behavior here, but that's a bit problematic because we probably only want to redact sensitive headers (like AUTHORIZATION).

import org.apache.iceberg.rest.HTTPHeaders.HTTPHeader;
import org.junit.jupiter.api.Test;

class TestHTTPHeaders {
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be good to add a test for duplicate behavior as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants