Skip to content

Commit

Permalink
Auth Manager API part 1: HTTPRequest, HTTPHeader (#11769)
Browse files Browse the repository at this point in the history
* Auth Manager API part 1: HTTPRequest, HTTPHeader

* review

* remove static methods

* verify error messages

* checkstyle

* review

* review

* review
  • Loading branch information
adutra authored Dec 17, 2024
1 parent ed06c9c commit a6cfc12
Show file tree
Hide file tree
Showing 4 changed files with 560 additions and 0 deletions.
110 changes: 110 additions & 0 deletions core/src/main/java/org/apache/iceberg/rest/HTTPHeaders.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.iceberg.rest;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.immutables.value.Value;

/**
* Represents a group of HTTP headers.
*
* <p>Header name comparison in this class is always case-insensitive, in accordance with RFC 2616.
*
* <p>This class exposes methods to convert to and from different representations such as maps and
* multimap, for easier access and manipulation – especially when dealing with multiple headers with
* the same name.
*/
@Value.Style(depluralize = true)
@Value.Immutable
@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"})
public interface HTTPHeaders {

HTTPHeaders EMPTY = of();

/** Returns all the header entries in this group. */
Set<HTTPHeader> entries();

/** Returns all the entries in this group for the given name (case-insensitive). */
default Set<HTTPHeader> entries(String name) {
return entries().stream()
.filter(header -> header.name().equalsIgnoreCase(name))
.collect(Collectors.toSet());
}

/** Returns whether this group contains an entry with the given name (case-insensitive). */
default boolean contains(String name) {
return entries().stream().anyMatch(header -> header.name().equalsIgnoreCase(name));
}

/**
* Adds the given header to the current group if no entry with the same name is already present.
* Returns a new instance with the added header, or the current instance if the header is already
* present.
*/
default HTTPHeaders putIfAbsent(HTTPHeader header) {
Preconditions.checkNotNull(header, "header");
return contains(header.name())
? this
: ImmutableHTTPHeaders.builder().from(this).addEntry(header).build();
}

/**
* Adds the given headers to the current group if no entries with same names are already present.
* Returns a new instance with the added headers, or the current instance if all headers are
* already present.
*/
default HTTPHeaders putIfAbsent(HTTPHeaders headers) {
Preconditions.checkNotNull(headers, "headers");
List<HTTPHeader> newHeaders =
headers.entries().stream().filter(e -> !contains(e.name())).collect(Collectors.toList());
return newHeaders.isEmpty()
? this
: ImmutableHTTPHeaders.builder().from(this).addAllEntries(newHeaders).build();
}

static HTTPHeaders of(HTTPHeader... headers) {
return ImmutableHTTPHeaders.builder().addEntries(headers).build();
}

/** Represents an HTTP header as a name-value pair. */
@Value.Style(redactedMask = "****", depluralize = true)
@Value.Immutable
@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"})
interface HTTPHeader {

String name();

@Value.Redacted
String value();

@Value.Check
default void check() {
if (name().isEmpty()) {
throw new IllegalArgumentException("Header name cannot be empty");
}
}

static HTTPHeader of(String name, String value) {
return ImmutableHTTPHeader.builder().name(name).value(value).build();
}
}
}
126 changes: 126 additions & 0 deletions core/src/main/java/org/apache/iceberg/rest/HTTPRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 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. */
@Value.Style(redactedMask = "****", depluralize = true)
@Value.Immutable
@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"})
public interface HTTPRequest {

enum HTTPMethod {
GET,
HEAD,
POST,
DELETE
}

/**
* Returns the base URI configured at the REST client level. The base URI is used to construct the
* full {@link #requestUri()}.
*/
URI baseUri();

/**
* Returns the full URI of this request. The URI is constructed from the base URI, path, and query
* parameters. It cannot be modified directly.
*/
@Value.Lazy
default URI requestUri() {
// 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. */
HTTPMethod method();

/** Returns the path of this request. */
String path();

/** Returns the query parameters of this request. */
Map<String, String> queryParameters();

/** Returns the headers of this request. */
@Value.Default
default HTTPHeaders headers() {
return HTTPHeaders.EMPTY;
}

/** Returns the raw, unencoded request body. */
@Nullable
@Value.Redacted
Object body();

/** Returns the encoded request body as a string. */
@Value.Lazy
@Nullable
@Value.Redacted
default String encodedBody() {
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;
}

/**
* Returns the {@link ObjectMapper} to use for encoding the request body. The default is {@link
* RESTObjectMapper#mapper()}.
*/
@Value.Default
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());
}
}
}
137 changes: 137 additions & 0 deletions core/src/test/java/org/apache/iceberg/rest/TestHTTPHeaders.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.iceberg.rest;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

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

class TestHTTPHeaders {

private final HTTPHeaders headers =
HTTPHeaders.of(
HTTPHeader.of("header1", "value1a"),
HTTPHeader.of("HEADER1", "value1b"),
HTTPHeader.of("header2", "value2"));

@Test
void entries() {
assertThat(headers.entries())
.containsExactlyInAnyOrder(
HTTPHeader.of("header1", "value1a"),
HTTPHeader.of("HEADER1", "value1b"),
HTTPHeader.of("header2", "value2"));

// duplicated entries
assertThat(
HTTPHeaders.of(HTTPHeader.of("header1", "value1"), HTTPHeader.of("header1", "value1"))
.entries())
.containsExactly(HTTPHeader.of("header1", "value1"));
}

@Test
void entriesByName() {
assertThat(headers.entries("header1"))
.containsExactlyInAnyOrder(
HTTPHeader.of("header1", "value1a"), HTTPHeader.of("HEADER1", "value1b"));
assertThat(headers.entries("HEADER1"))
.containsExactlyInAnyOrder(
HTTPHeader.of("header1", "value1a"), HTTPHeader.of("HEADER1", "value1b"));
assertThat(headers.entries("header2"))
.containsExactlyInAnyOrder(HTTPHeader.of("header2", "value2"));
assertThat(headers.entries("HEADER2"))
.containsExactlyInAnyOrder(HTTPHeader.of("header2", "value2"));
assertThat(headers.entries("header3")).isEmpty();
assertThat(headers.entries("HEADER3")).isEmpty();
assertThat(headers.entries(null)).isEmpty();
}

@Test
void contains() {
assertThat(headers.contains("header1")).isTrue();
assertThat(headers.contains("HEADER1")).isTrue();
assertThat(headers.contains("header2")).isTrue();
assertThat(headers.contains("HEADER2")).isTrue();
assertThat(headers.contains("header3")).isFalse();
assertThat(headers.contains("HEADER3")).isFalse();
assertThat(headers.contains(null)).isFalse();
}

@Test
void putIfAbsentHTTPHeader() {
HTTPHeaders actual = headers.putIfAbsent(HTTPHeader.of("Header1", "value1c"));
assertThat(actual).isSameAs(headers);

actual = headers.putIfAbsent(HTTPHeader.of("header3", "value3"));
assertThat(actual.entries())
.containsExactly(
HTTPHeader.of("header1", "value1a"),
HTTPHeader.of("HEADER1", "value1b"),
HTTPHeader.of("header2", "value2"),
HTTPHeader.of("header3", "value3"));

assertThatThrownBy(() -> headers.putIfAbsent((HTTPHeader) null))
.isInstanceOf(NullPointerException.class)
.hasMessage("header");
}

@Test
void putIfAbsentHTTPHeaders() {
HTTPHeaders actual = headers.putIfAbsent(HTTPHeaders.of(HTTPHeader.of("Header1", "value1c")));
assertThat(actual).isSameAs(headers);

actual =
headers.putIfAbsent(
ImmutableHTTPHeaders.builder()
.addEntry(HTTPHeader.of("Header1", "value1c"))
.addEntry(HTTPHeader.of("header3", "value3"))
.build());
assertThat(actual)
.isEqualTo(
ImmutableHTTPHeaders.builder()
.addEntries(
HTTPHeader.of("header1", "value1a"),
HTTPHeader.of("HEADER1", "value1b"),
HTTPHeader.of("header2", "value2"),
HTTPHeader.of("header3", "value3"))
.build());

assertThatThrownBy(() -> headers.putIfAbsent((HTTPHeaders) null))
.isInstanceOf(NullPointerException.class)
.hasMessage("headers");
}

@Test
void invalidHeader() {
// invalid input (null name or value)
assertThatThrownBy(() -> HTTPHeader.of(null, "value1"))
.isInstanceOf(NullPointerException.class)
.hasMessage("name");
assertThatThrownBy(() -> HTTPHeader.of("header1", null))
.isInstanceOf(NullPointerException.class)
.hasMessage("value");

// invalid input (empty name)
assertThatThrownBy(() -> HTTPHeader.of("", "value1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Header name cannot be empty");
}
}
Loading

0 comments on commit a6cfc12

Please sign in to comment.