Skip to content
Draft
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
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: Map<URI, List<Pair<String, String>>>? = null,

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
?: settings.http?.rewrites()
}

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

private val externalModuleReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(project?.evaluatorSettings?.externalModuleReaders ?: emptyMap()) +
cliOptions.externalModuleReaders
Expand Down Expand Up @@ -247,6 +251,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,7 +31,9 @@ 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
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader
import org.pkl.core.evaluatorSettings.TraceMode
import org.pkl.core.runtime.VmUtils
Expand Down Expand Up @@ -275,6 +277,58 @@ class BaseOptions : OptionGroup() {
.multiple()
.toMap()

val httpHeaders: Map<URI, List<PPair<String, String>>> by
option(
names = arrayOf("--http-headers"),
metavar = "<uri>=<header name>:<header value>[,<header name>:<header value>...]",
help = "HTTP header to add to the request.",
)
.convert { it ->
val (uriStr, headers) =
it.split("=", limit = 2).let { parts ->
require(parts.size == 2) {
"Headers must be in the form of <prefix>=<header name>:<header value>"
}
parts[0] to parts[1]
}

try {
val uri = URI(uriStr.trim())

val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""")
val headerPairs =
headers.split(',').map { header ->
val (headerName, headerValue) =
headerRegex.find(header)?.destructured
?: fail("Header '$header' is not in 'name:value' format.")
require(PklEvaluatorSettings.HEADER_NAME_REGEX.matcher(headerName).matches()) {
"HTTP header name '$headerName' has invalid syntax."
}
require(PklEvaluatorSettings.HEADER_VALUE_REGEX.matcher(headerValue).matches()) {
"HTTP header value '$headerValue' has invalid syntax"
}
PPair(headerName, headerValue)
}
uri to headerPairs
} catch (e: IllegalArgumentException) {
fail(e.message!!)
} catch (e: URISyntaxException) {
val message = buildString {
append("HTTP headers target `${e.input}` has invalid syntax (${e.reason}).")
if (e.index > -1) {
append("\n\n")
append(e.input)
append("\n")
append(" ".repeat(e.index))
append("^")
}
}
fail(message)
}
}
.multiple()
.toMap()

val externalModuleReaders: Map<String, ExternalReader> by
option(
names = arrayOf("--external-module-reader"),
Expand Down Expand Up @@ -340,6 +394,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
Expand Up @@ -30,6 +30,7 @@
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;
Expand All @@ -53,6 +54,10 @@ public record PklEvaluatorSettings(
@Nullable Map<String, ExternalReader> externalResourceReaders,
@Nullable TraceMode traceMode) {

public static final Pattern HEADER_NAME_REGEX = Pattern.compile("^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$");
public static final Pattern HEADER_VALUE_REGEX =
Pattern.compile("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$");

/** Initializes a {@link PklEvaluatorSettings} from a raw object representation. */
@SuppressWarnings("unchecked")
public static PklEvaluatorSettings parse(
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 Map<URI, 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,32 @@ 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 headers = http.getProperty("headers");
HashMap<URI, List<org.pkl.core.Pair<String, String>>> parsedHeaders = null;
if (!(headers instanceof PNull)) {
parsedHeaders = new HashMap<>();
var headersMap = (Map<String, List<Pair<String, String>>>) headers;
for (var entry : headersMap.entrySet()) {
var uri = entry.getKey();
var pairs = entry.getValue();
for (var pair : pairs) {
if (!HEADER_NAME_REGEX.matcher(pair.getFirst()).matches()) {
throw new PklException(ErrorMessages.create("invalidHeaderName", pair.getFirst()));
}
if (!HEADER_VALUE_REGEX.matcher(pair.getSecond()).matches()) {
throw new PklException(
ErrorMessages.create("invalidHeaderValue", pair.getSecond()));
}
}
try {
parsedHeaders.put(new URI(uri), pairs);
} catch (URISyntaxException e) {
throw new PklException(ErrorMessages.create("invalidUri", e.getInput()));
}
}
}
return new Http(proxy, parsedRewrites, parsedHeaders);
} else {
throw PklBugException.unreachableCode();
}
Expand Down
9 changes: 9 additions & 0 deletions pkl-core/src/main/java/org/pkl/core/http/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import org.pkl.core.Pair;
import org.pkl.core.util.Nullable;

/**
Expand Down Expand Up @@ -150,6 +151,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(Map<URI, List<Pair<String, String>>> headers);

/**
* Creates a new {@code HttpClient} from the current state of this builder.
*
Expand Down
11 changes: 10 additions & 1 deletion pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.pkl.core.Pair;
import org.pkl.core.Release;
import org.pkl.core.http.HttpClient.Builder;

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

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

@Override
public Builder setHeaders(Map<URI, List<Pair<String, String>>> headers) {
this.headers = headers;
return this;
}

@Override
public HttpClient build() {
return doBuild().get();
Expand All @@ -127,7 +135,8 @@ private Supplier<HttpClient> doBuild() {
this.proxySelector != null ? this.proxySelector : java.net.ProxySelector.getDefault();
return () -> {
var jdkClient =
new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector);
new JdkHttpClient(
certificateFiles, certificateBytes, connectTimeout, proxySelector, headers);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites);
};
}
Expand Down
20 changes: 17 additions & 3 deletions pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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 @@ -22,6 +22,7 @@
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
Expand All @@ -41,11 +42,13 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import javax.annotation.concurrent.ThreadSafe;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.TrustManagerFactory;
import org.pkl.core.Pair;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.Exceptions;

Expand All @@ -54,6 +57,7 @@
final class JdkHttpClient implements HttpClient {
// non-private for testing
final java.net.http.HttpClient underlying;
final Map<URI, List<Pair<String, String>>> headers;

// call java.net.http.HttpClient.close() if available (JDK 21+)
private static final MethodHandle closeMethod;
Expand All @@ -77,21 +81,31 @@ final class JdkHttpClient implements HttpClient {
List<Path> certificateFiles,
List<ByteBuffer> certificateBytes,
Duration connectTimeout,
java.net.ProxySelector proxySelector) {
java.net.ProxySelector proxySelector,
Map<URI, List<Pair<String, String>>> headers) {
underlying =
java.net.http.HttpClient.newBuilder()
.sslContext(createSslContext(certificateFiles, certificateBytes))
.connectTimeout(connectTimeout)
.proxy(proxySelector)
.followRedirects(Redirect.NORMAL)
.build();
this.headers = headers;
}

@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException {
try {
return underlying.send(request, responseBodyHandler);
var wrappedRequestBuilder = HttpRequest.newBuilder(request, (name, value) -> true);
for (var entry : headers.entrySet()) {
if (RequestRewritingClient.matchesRewriteRule(request.uri(), entry.getKey())) {
for (var value : entry.getValue()) {
wrappedRequestBuilder.header(value.getFirst(), value.getSecond());
}
}
}
return underlying.send(wrappedRequestBuilder.build(), responseBodyHandler);
} catch (ConnectException e) {
// original exception has no message
throw new ConnectException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1076,3 +1076,9 @@ invalidStringBase64=\

characterCodingException=\
Invalid bytes for charset "{0}".

invalidHeaderName=\
HTTP header name `{0}` has invalid syntax.

invalidHeaderValue=\
HTTP header value `{0}` has invalid syntax.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import org.pkl.commons.writeString
import org.pkl.core.Evaluator
import org.pkl.core.ModuleSource
import org.pkl.core.PObject
import org.pkl.core.Pair as PPair
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings
import org.pkl.core.settings.PklSettings.Editor

Expand Down Expand Up @@ -64,6 +65,11 @@ class PklSettingsTest {
rewrites {
["https://foo.com/"] = "https://bar.com/"
}
headers {
["https://foo.com/"] {
Pair("X-Foo", "bar")
}
}
}
"""
.trimIndent()
Expand All @@ -77,7 +83,9 @@ class PklSettingsTest {
listOf("example.com", "pkg.pkl-lang.org"),
),
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
mapOf(URI("https://foo.com/") to listOf(PPair("X-Foo", "bar"))),
)

assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
}

Expand All @@ -102,6 +110,7 @@ class PklSettingsTest {
PklEvaluatorSettings.Http(
PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()),
null,
null,
)
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
}
Expand Down
Loading