Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
310 changes: 155 additions & 155 deletions pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.util.regex.Pattern
import org.pkl.core.Pair
import org.pkl.core.evaluatorSettings.Color
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader
import org.pkl.core.evaluatorSettings.TraceMode
Expand Down Expand Up @@ -144,6 +145,9 @@ data class CliBaseOptions(
/** URL prefixes to rewrite. */
val httpRewrites: Map<URI, URI>? = null,

/** HTTP headers to add to the request. */
val httpHeaders: List<Pair<Pattern, List<Pair<String, String>>>>? = null,

/** External module reader process specs */
val externalModuleReaders: Map<String, ExternalReader> = mapOf(),

Expand Down Expand Up @@ -192,10 +196,9 @@ data class CliBaseOptions(
// sort modules to make cli output independent of source module order
.sorted()

val normalizedSettingsModule: URI? =
settings?.let { uri ->
if (uri.isAbsolute) uri else IoUtils.resolve(normalizedWorkingDir.toUri(), uri)
}
val normalizedSettingsModule: URI? = settings?.let { uri ->
if (uri.isAbsolute) uri else IoUtils.resolve(normalizedWorkingDir.toUri(), uri)
}

/** [modulePath] after normalization. */
val normalizedModulePath: List<Path>? = modulePath?.map(normalizedWorkingDir::resolve)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites()
}

private val httpHeaders: List<Pair<Pattern, List<Pair<String, String>>>>? by lazy {
cliOptions.httpHeaders ?: project?.evaluatorSettings?.http?.headers ?: settings.http?.headers
}

protected val externalModuleReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(evaluatorSettings?.externalModuleReaders ?: emptyMap()) + cliOptions.externalModuleReaders
}
Expand Down Expand Up @@ -277,6 +281,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
setProxy(proxyAddress, noProxy ?: listOf())
}
httpRewrites?.let(::setRewrites)
httpHeaders?.let(::setHeaders)
// Lazy building significantly reduces execution time of commands that do minimal work.
// However, it means that HTTP client initialization errors won't surface until an HTTP
// request is made.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import java.util.regex.Pattern
import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliException
import org.pkl.commons.shlex
import org.pkl.core.Pair as PPair
import org.pkl.core.evaluatorSettings.Color
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader
import org.pkl.core.evaluatorSettings.TraceMode
import org.pkl.core.runtime.VmUtils
import org.pkl.core.util.GlobResolver
import org.pkl.core.util.IoUtils

@Suppress("MemberVisibilityCanBePrivate")
Expand Down Expand Up @@ -285,6 +287,37 @@ class BaseOptions : OptionGroup() {
.multiple()
.toMap()

val httpHeaders: List<PPair<Pattern, List<PPair<String, String>>>> by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is more of a nit, but: List<PPair<Pattern, List<PPair<String, String>>>> is a quite generic type that doesn't convey much information. It would be better to have a properly-named record that stores the URL glob and the header name-value pairs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree this type's gotten a bit out of hand and a record would be a welcome improvement. I'm not eager to block this PR more than necessary, so I'm fine leaving this as follow-up work.

option(
names = arrayOf("--http-headers"),
metavar = "<url-pattern>=<header name>:<header value>",
help = "HTTP header to add to the request.",
)
.splitPair()
.transformAll { it ->
val headersMap = mutableMapOf<String, MutableList<PPair<String, String>>>()

try {
val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""")
for ((stringPattern, header) in it) {
val (headerName, headerValue) =
headerRegex.find(header)?.destructured
?: fail("Header '$header' is not in 'name:value' format.")
IoUtils.validateHeaderName(headerName)
IoUtils.validateHeaderValue(headerValue)
headersMap
.computeIfAbsent(stringPattern) { mutableListOf() }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The pattern here isn't validated by the same rule as the pattern values in pkl:EvaluatorSettings

.add(PPair(headerName, headerValue))
}

headersMap.entries.map { PPair(GlobResolver.toRegexPattern(it.key), it.value) }
} catch (e: IllegalArgumentException) {
fail(e.message!!)
} catch (e: GlobResolver.InvalidGlobPatternException) {
fail(e.message!!)
}
}

val externalModuleReaders: Map<String, ExternalReader> by
option(
names = arrayOf("--external-module-reader"),
Expand Down Expand Up @@ -351,6 +384,7 @@ class BaseOptions : OptionGroup() {
httpProxy = proxy,
httpNoProxy = noProxy,
httpRewrites = httpRewrites.ifEmpty { null },
httpHeaders = httpHeaders.ifEmpty { null },
externalModuleReaders = externalModuleReaders,
externalResourceReaders = externalResourceReaders,
traceMode = traceMode,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. 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.
Expand All @@ -18,6 +18,7 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
Expand All @@ -27,13 +28,17 @@
import java.util.function.BiFunction;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.pkl.core.Duration;
import org.pkl.core.PNull;
import org.pkl.core.PObject;
import org.pkl.core.Pair;
import org.pkl.core.PklBugException;
import org.pkl.core.PklException;
import org.pkl.core.Value;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.GlobResolver;
import org.pkl.core.util.GlobResolver.InvalidGlobPatternException;
import org.pkl.core.util.Nullable;

/** Java version of {@code pkl.EvaluatorSettings}. */
Expand Down Expand Up @@ -126,8 +131,11 @@ public static PklEvaluatorSettings parse(
traceMode == null ? null : TraceMode.valueOf(traceMode.toUpperCase()));
}

public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) {
public static final Http DEFAULT = new Http(null, Collections.emptyMap());
public record Http(
@Nullable Proxy proxy,
@Nullable Map<URI, URI> rewrites,
@Nullable List<Pair<Pattern, List<Pair<String, String>>>> headers) {
public static final Http DEFAULT = new Http(null, Collections.emptyMap(), null);

@SuppressWarnings("unchecked")
public static @Nullable Http parse(@Nullable Value input) {
Expand All @@ -136,10 +144,9 @@ public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) {
} else if (input instanceof PObject http) {
var proxy = Proxy.parse((Value) http.getProperty("proxy"));
var rewrites = http.getProperty("rewrites");
if (rewrites instanceof PNull) {
return new Http(proxy, null);
} else {
var parsedRewrites = new HashMap<URI, URI>();
HashMap<URI, URI> parsedRewrites = null;
if (!(rewrites instanceof PNull)) {
parsedRewrites = new HashMap<>();
for (var entry : ((Map<String, String>) rewrites).entrySet()) {
var key = entry.getKey();
var value = entry.getValue();
Expand All @@ -149,8 +156,37 @@ public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) {
throw new PklException(ErrorMessages.create("invalidUri", e.getInput()));
}
}
return new Http(proxy, parsedRewrites);
}
var headerDefs = http.getProperty("headers");
List<Pair<Pattern, List<Pair<String, String>>>> parsedHeaderDefs = null;
if (!(headerDefs instanceof PNull)) {
parsedHeaderDefs = new ArrayList<>();
var headerDefsMap = (Map<String, Map<String, Object>>) headerDefs;
for (var entry : headerDefsMap.entrySet()) {
var stringPattern = entry.getKey();
var headersMap = entry.getValue();
try {
var urlPattern = GlobResolver.toRegexPattern(stringPattern);
var pairs =
headersMap.entrySet().stream()
.flatMap(
header -> {
var value = header.getValue();
if (value instanceof List) {
return ((List<String>) value)
.stream().map(v -> new Pair(header.getKey(), v));
} else {
return Stream.of(new Pair(header.getKey(), value));
}
})
.toList();
parsedHeaderDefs.add(new Pair(urlPattern, pairs));
} catch (InvalidGlobPatternException e) {
throw new PklException(ErrorMessages.create("invalidUri", stringPattern));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
throw new PklException(ErrorMessages.create("invalidUri", stringPattern));
throw new PklException(ErrorMessages.create("invalidGlobPattern", stringPattern));

}
}
}
return new Http(proxy, parsedRewrites, parsedHeaderDefs);
} else {
throw PklBugException.unreachableCode();
}
Expand Down
12 changes: 11 additions & 1 deletion pkl-core/src/main/java/org/pkl/core/http/HttpClient.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. 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.
Expand All @@ -23,7 +23,9 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import org.pkl.core.Pair;
import org.pkl.core.util.Nullable;

/**
Expand Down Expand Up @@ -150,6 +152,14 @@ interface Builder {
*/
Builder addRewrite(URI sourcePrefix, URI targetPrefix);

/**
* Sets the HTTP headers for the request, replacing any previously configured headers.
*
* <p>This method clears all existing headers and replaces them with the contents of the
* provided map.
*/
Builder setHeaders(List<Pair<Pattern, List<Pair<String, String>>>> headers);

/**
* Creates a new {@code HttpClient} from the current state of this builder.
*
Expand Down
14 changes: 12 additions & 2 deletions pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. 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.
Expand Down Expand Up @@ -27,6 +27,8 @@
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import org.pkl.core.Pair;
import org.pkl.core.Release;
import org.pkl.core.http.HttpClient.Builder;

Expand All @@ -39,6 +41,7 @@ final class HttpClientBuilder implements HttpClient.Builder {
private int testPort = -1;
private ProxySelector proxySelector;
private Map<URI, URI> rewrites = new HashMap<>();
private List<Pair<Pattern, List<Pair<String, String>>>> headers = new ArrayList<>();

HttpClientBuilder() {
var release = Release.current();
Expand Down Expand Up @@ -110,6 +113,12 @@ public Builder addRewrite(URI sourcePrefix, URI targetPrefix) {
return this;
}

@Override
public Builder setHeaders(List<Pair<Pattern, List<Pair<String, String>>>> headers) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it would be better if this signature matched the in-language evaluator settings.

I think this should be something like:

setHeaderRules(Map<String, Map<String, List<String>>> headerRules)

Where the top-level map key is the glob pattern.
This method can throw IllegalArgumentException if a glob pattern is invalid.

And, I think we should also have:

addHeaderRule(String globPattern, Map<String, List<String>> headers)

It'd also be good to avoid using Pair here.

this.headers = headers;
return this;
}

@Override
public HttpClient build() {
return doBuild().get();
Expand All @@ -128,7 +137,8 @@ private Supplier<HttpClient> doBuild() {
return () -> {
var jdkClient =
new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites);
return new RequestRewritingClient(
userAgent, requestTimeout, testPort, jdkClient, rewrites, headers);
};
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. 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.
Expand Down Expand Up @@ -28,7 +28,10 @@
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.annotation.concurrent.ThreadSafe;
import org.pkl.core.Pair;
import org.pkl.core.PklBugException;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.Nullable;
Expand All @@ -54,6 +57,7 @@ final class RequestRewritingClient implements HttpClient {
final int testPort;
final HttpClient delegate;
private final List<Entry<URI, URI>> rewrites;
private final List<Pair<Pattern, List<Pair<String, String>>>> headers;

private final AtomicBoolean closed = new AtomicBoolean();

Expand All @@ -62,7 +66,8 @@ final class RequestRewritingClient implements HttpClient {
Duration requestTimeout,
int testPort,
HttpClient delegate,
Map<URI, URI> rewrites) {
Map<URI, URI> rewrites,
List<Pair<Pattern, List<Pair<String, String>>>> headers) {
this.userAgent = userAgent;
this.requestTimeout = requestTimeout;
this.testPort = testPort;
Expand All @@ -72,6 +77,7 @@ final class RequestRewritingClient implements HttpClient {
.map((it) -> Map.entry(normalizeRewrite(it.getKey()), normalizeRewrite(it.getValue())))
.sorted(Comparator.comparingInt((it) -> -it.getKey().toString().length()))
.toList();
this.headers = headers;
}

@Override
Expand Down Expand Up @@ -112,6 +118,9 @@ private HttpRequest rewriteRequest(HttpRequest original) {
.map()
.forEach((name, values) -> values.forEach(value -> builder.header(name, value)));
builder.setHeader("User-Agent", userAgent);
for (var header : this.getHeaders(original.uri())) {
builder.header(header.getFirst(), header.getSecond());
}

var method = original.method();
original
Expand Down Expand Up @@ -216,6 +225,16 @@ private URI rewriteUri(URI uri) {
return ret;
}

private List<Pair<String, String>> getHeaders(URI uri) {
return headers.stream()
.flatMap(
rule ->
rule.getFirst().asPredicate().test(uri.toString())
? rule.getSecond().stream()
: Stream.empty())
.toList();
}

private void checkNotClosed(HttpRequest request) {
if (closed.get()) {
throw new IllegalStateException(
Expand Down
Loading
Loading