Add support for customizing HTTP headers (#1196)

This PR adds support for custom HTTP headers, introducing a
`--http-header` CLI flag to accept `key=value` pairs. These headers can
also be specified within the `setting.pkl` file.

Closes #633

SPICE: https://github.com/apple/pkl-evolution/pull/24

---------

Co-authored-by: Jen Basch <jbasch94@gmail.com>
Co-authored-by: Islon Scherer <islonscherer@gmail.com>
This commit is contained in:
Jeaeun Kim
2026-05-13 05:53:59 +09:00
committed by GitHub
parent fe58405220
commit 14085c18bb
14 changed files with 358 additions and 18 deletions
@@ -20,6 +20,7 @@ import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration import java.time.Duration
import java.util.regex.Pattern import java.util.regex.Pattern
import org.pkl.core.Pair
import org.pkl.core.evaluatorSettings.Color import org.pkl.core.evaluatorSettings.Color
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader
import org.pkl.core.evaluatorSettings.TraceMode import org.pkl.core.evaluatorSettings.TraceMode
@@ -144,6 +145,9 @@ data class CliBaseOptions(
/** URL prefixes to rewrite. */ /** URL prefixes to rewrite. */
val httpRewrites: Map<URI, URI>? = null, 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 */ /** External module reader process specs */
val externalModuleReaders: Map<String, ExternalReader> = mapOf(), val externalModuleReaders: Map<String, ExternalReader> = mapOf(),
@@ -218,6 +218,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites() 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 { protected val externalModuleReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(evaluatorSettings?.externalModuleReaders ?: emptyMap()) + cliOptions.externalModuleReaders (evaluatorSettings?.externalModuleReaders ?: emptyMap()) + cliOptions.externalModuleReaders
} }
@@ -277,6 +281,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
setProxy(proxyAddress, noProxy ?: listOf()) setProxy(proxyAddress, noProxy ?: listOf())
} }
httpRewrites?.let(::setRewrites) httpRewrites?.let(::setRewrites)
httpHeaders?.let(::setHeaders)
// Lazy building significantly reduces execution time of commands that do minimal work. // 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 // However, it means that HTTP client initialization errors won't surface until an HTTP
// request is made. // request is made.
@@ -31,10 +31,12 @@ import java.util.regex.Pattern
import org.pkl.commons.cli.CliBaseOptions import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliException import org.pkl.commons.cli.CliException
import org.pkl.commons.shlex import org.pkl.commons.shlex
import org.pkl.core.Pair as PPair
import org.pkl.core.evaluatorSettings.Color import org.pkl.core.evaluatorSettings.Color
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader
import org.pkl.core.evaluatorSettings.TraceMode import org.pkl.core.evaluatorSettings.TraceMode
import org.pkl.core.runtime.VmUtils import org.pkl.core.runtime.VmUtils
import org.pkl.core.util.GlobResolver
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
@@ -285,6 +287,37 @@ class BaseOptions : OptionGroup() {
.multiple() .multiple()
.toMap() .toMap()
val httpHeaders: List<PPair<Pattern, List<PPair<String, String>>>> by
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() }
.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 val externalModuleReaders: Map<String, ExternalReader> by
option( option(
names = arrayOf("--external-module-reader"), names = arrayOf("--external-module-reader"),
@@ -351,6 +384,7 @@ class BaseOptions : OptionGroup() {
httpProxy = proxy, httpProxy = proxy,
httpNoProxy = noProxy, httpNoProxy = noProxy,
httpRewrites = httpRewrites.ifEmpty { null }, httpRewrites = httpRewrites.ifEmpty { null },
httpHeaders = httpHeaders.ifEmpty { null },
externalModuleReaders = externalModuleReaders, externalModuleReaders = externalModuleReaders,
externalResourceReaders = externalResourceReaders, externalResourceReaders = externalResourceReaders,
traceMode = traceMode, traceMode = traceMode,
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ package org.pkl.core.evaluatorSettings;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -27,13 +28,17 @@ import java.util.Objects;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.pkl.core.Duration; import org.pkl.core.Duration;
import org.pkl.core.PNull; import org.pkl.core.PNull;
import org.pkl.core.PObject; import org.pkl.core.PObject;
import org.pkl.core.Pair;
import org.pkl.core.PklBugException; import org.pkl.core.PklBugException;
import org.pkl.core.PklException; import org.pkl.core.PklException;
import org.pkl.core.Value; import org.pkl.core.Value;
import org.pkl.core.util.ErrorMessages; 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; import org.pkl.core.util.Nullable;
/** Java version of {@code pkl.EvaluatorSettings}. */ /** Java version of {@code pkl.EvaluatorSettings}. */
@@ -126,8 +131,11 @@ public record PklEvaluatorSettings(
traceMode == null ? null : TraceMode.valueOf(traceMode.toUpperCase())); traceMode == null ? null : TraceMode.valueOf(traceMode.toUpperCase()));
} }
public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) { public record Http(
public static final Http DEFAULT = new Http(null, Collections.emptyMap()); @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") @SuppressWarnings("unchecked")
public static @Nullable Http parse(@Nullable Value input) { public static @Nullable Http parse(@Nullable Value input) {
@@ -136,10 +144,9 @@ public record PklEvaluatorSettings(
} else if (input instanceof PObject http) { } else if (input instanceof PObject http) {
var proxy = Proxy.parse((Value) http.getProperty("proxy")); var proxy = Proxy.parse((Value) http.getProperty("proxy"));
var rewrites = http.getProperty("rewrites"); var rewrites = http.getProperty("rewrites");
if (rewrites instanceof PNull) { HashMap<URI, URI> parsedRewrites = null;
return new Http(proxy, null); if (!(rewrites instanceof PNull)) {
} else { parsedRewrites = new HashMap<>();
var parsedRewrites = new HashMap<URI, URI>();
for (var entry : ((Map<String, String>) rewrites).entrySet()) { for (var entry : ((Map<String, String>) rewrites).entrySet()) {
var key = entry.getKey(); var key = entry.getKey();
var value = entry.getValue(); var value = entry.getValue();
@@ -149,8 +156,37 @@ public record PklEvaluatorSettings(
throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); 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));
}
}
}
return new Http(proxy, parsedRewrites, parsedHeaderDefs);
} else { } else {
throw PklBugException.unreachableCode(); throw PklBugException.unreachableCode();
} }
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -23,7 +23,9 @@ import java.net.http.HttpTimeoutException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import org.pkl.core.Pair;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
/** /**
@@ -150,6 +152,14 @@ public interface HttpClient extends AutoCloseable {
*/ */
Builder addRewrite(URI sourcePrefix, URI targetPrefix); 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. * Creates a new {@code HttpClient} from the current state of this builder.
* *
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -27,6 +27,8 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.regex.Pattern;
import org.pkl.core.Pair;
import org.pkl.core.Release; import org.pkl.core.Release;
import org.pkl.core.http.HttpClient.Builder; import org.pkl.core.http.HttpClient.Builder;
@@ -39,6 +41,7 @@ final class HttpClientBuilder implements HttpClient.Builder {
private int testPort = -1; private int testPort = -1;
private ProxySelector proxySelector; private ProxySelector proxySelector;
private Map<URI, URI> rewrites = new HashMap<>(); private Map<URI, URI> rewrites = new HashMap<>();
private List<Pair<Pattern, List<Pair<String, String>>>> headers = new ArrayList<>();
HttpClientBuilder() { HttpClientBuilder() {
var release = Release.current(); var release = Release.current();
@@ -110,6 +113,12 @@ final class HttpClientBuilder implements HttpClient.Builder {
return this; return this;
} }
@Override
public Builder setHeaders(List<Pair<Pattern, List<Pair<String, String>>>> headers) {
this.headers = headers;
return this;
}
@Override @Override
public HttpClient build() { public HttpClient build() {
return doBuild().get(); return doBuild().get();
@@ -128,7 +137,8 @@ final class HttpClientBuilder implements HttpClient.Builder {
return () -> { return () -> {
var jdkClient = var jdkClient =
new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector); new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites); return new RequestRewritingClient(
userAgent, requestTimeout, testPort, jdkClient, rewrites, headers);
}; };
} }
} }
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -28,7 +28,10 @@ import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.annotation.concurrent.ThreadSafe; import javax.annotation.concurrent.ThreadSafe;
import org.pkl.core.Pair;
import org.pkl.core.PklBugException; import org.pkl.core.PklBugException;
import org.pkl.core.util.HttpUtils; import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
@@ -54,6 +57,7 @@ final class RequestRewritingClient implements HttpClient {
final int testPort; final int testPort;
final HttpClient delegate; final HttpClient delegate;
private final List<Entry<URI, URI>> rewrites; private final List<Entry<URI, URI>> rewrites;
private final List<Pair<Pattern, List<Pair<String, String>>>> headers;
private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean();
@@ -62,7 +66,8 @@ final class RequestRewritingClient implements HttpClient {
Duration requestTimeout, Duration requestTimeout,
int testPort, int testPort,
HttpClient delegate, HttpClient delegate,
Map<URI, URI> rewrites) { Map<URI, URI> rewrites,
List<Pair<Pattern, List<Pair<String, String>>>> headers) {
this.userAgent = userAgent; this.userAgent = userAgent;
this.requestTimeout = requestTimeout; this.requestTimeout = requestTimeout;
this.testPort = testPort; this.testPort = testPort;
@@ -72,6 +77,7 @@ final class RequestRewritingClient implements HttpClient {
.map((it) -> Map.entry(normalizeRewrite(it.getKey()), normalizeRewrite(it.getValue()))) .map((it) -> Map.entry(normalizeRewrite(it.getKey()), normalizeRewrite(it.getValue())))
.sorted(Comparator.comparingInt((it) -> -it.getKey().toString().length())) .sorted(Comparator.comparingInt((it) -> -it.getKey().toString().length()))
.toList(); .toList();
this.headers = headers;
} }
@Override @Override
@@ -112,6 +118,9 @@ final class RequestRewritingClient implements HttpClient {
.map() .map()
.forEach((name, values) -> values.forEach(value -> builder.header(name, value))); .forEach((name, values) -> values.forEach(value -> builder.header(name, value)));
builder.setHeader("User-Agent", userAgent); builder.setHeader("User-Agent", userAgent);
for (var header : this.getHeaders(original.uri())) {
builder.header(header.getFirst(), header.getSecond());
}
var method = original.method(); var method = original.method();
original original
@@ -216,6 +225,16 @@ final class RequestRewritingClient implements HttpClient {
return ret; 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) { private void checkNotClosed(HttpRequest request) {
if (closed.get()) { if (closed.get()) {
throw new IllegalStateException( throw new IllegalStateException(
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -52,6 +52,34 @@ public final class IoUtils {
private static final Pattern windowsPathLike = Pattern.compile("\\w:\\\\.*"); private static final Pattern windowsPathLike = Pattern.compile("\\w:\\\\.*");
private static final Pattern headerNameLike = Pattern.compile("^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$");
private static final Pattern headerValueLike =
Pattern.compile("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$");
private static final String[] reservedHeaderNames = {
"accept-charset",
"accept-encoding",
"connection",
"content-length",
"cookie",
"date",
"dnt",
"expect",
"host",
"keep-alive",
"origin",
"permissions-policy",
"referer",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via"
};
private static final String[] reservedHeaderPrefixes = {"proxy-", "sec-", "access-control-"};
private IoUtils() {} private IoUtils() {}
public static URL toUrl(URI uri) throws IOException { public static URL toUrl(URI uri) throws IOException {
@@ -546,7 +574,7 @@ public final class IoUtils {
} }
// don't use ServiceLoader.load(Class) // don't use ServiceLoader.load(Class)
// because loading services from thread context class loader doesn't work inside gradle plugins // because loading services from thread context class loader doesn't work inside Gradle plugins
return ServiceLoader.load(serviceClass, IoUtils.class.getClassLoader()); return ServiceLoader.load(serviceClass, IoUtils.class.getClassLoader());
} }
@@ -854,4 +882,38 @@ public final class IoUtils {
"Rewrite rule must end with '/', but was '%s'".formatted(rewrite)); "Rewrite rule must end with '/', but was '%s'".formatted(rewrite));
} }
} }
private static boolean isReservedHeaderName(String headerName) {
return Arrays.stream(reservedHeaderNames).anyMatch((reserved) -> headerName.equals(reserved));
}
private static boolean hasReservedHeaderPrefix(String headerName) {
return Arrays.stream(reservedHeaderPrefixes)
.anyMatch((prefix) -> headerName.startsWith(prefix));
}
public static void validateHeaderName(String headerName) {
if (isReservedHeaderName(headerName)) {
throw new IllegalArgumentException(
"HTTP header '%s' is a reserved header".formatted(headerName));
}
if (hasReservedHeaderPrefix(headerName)) {
throw new IllegalArgumentException(
"HTTP header '%s' starts with a reserved header prefix".formatted(headerName));
}
if (!headerNameLike.matcher(headerName).matches()) {
throw new IllegalArgumentException(
"HTTP header name '%s' has an invalid syntax".formatted(headerName));
}
}
public static void validateHeaderValue(String headerValue) {
if (!headerValueLike.matcher(headerValue).matches()) {
throw new IllegalArgumentException(
"HTTP header value '%s' has an invalid syntax".formatted(headerValue));
}
}
} }
@@ -1124,3 +1124,9 @@ Option {1}s must not overlap with built-in options.
commandFlagInvalidType=\ commandFlagInvalidType=\
Option `{0}` with annotation `@{1}` has invalid type `{2}`.\n\ Option `{0}` with annotation `@{1}` has invalid type `{2}`.\n\
Expected type: `{3}` Expected type: `{3}`
invalidHeaderName=\
HTTP header name `{0}` has invalid syntax.
invalidHeaderValue=\
HTTP header value `{0}` has invalid syntax.
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -21,9 +21,11 @@ import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse.BodyHandlers import java.net.http.HttpResponse.BodyHandlers
import java.time.Duration import java.time.Duration
import java.util.regex.Pattern
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatList import org.assertj.core.api.Assertions.assertThatList
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.pkl.core.Pair as PPair
class RequestRewritingClientTest { class RequestRewritingClientTest {
private val captured = RequestCapturingClient() private val captured = RequestCapturingClient()
@@ -34,6 +36,7 @@ class RequestRewritingClientTest {
-1, -1,
captured, captured,
mapOf(URI("https://foo/") to URI("https://bar/")), mapOf(URI("https://foo/") to URI("https://bar/")),
listOf(),
) )
private val exampleUri = URI("https://example.com/foo/bar.html") private val exampleUri = URI("https://example.com/foo/bar.html")
private val exampleRequest = HttpRequest.newBuilder(exampleUri).build() private val exampleRequest = HttpRequest.newBuilder(exampleUri).build()
@@ -121,7 +124,8 @@ class RequestRewritingClientTest {
@Test @Test
fun `rewrites port 0 if test port is set`() { fun `rewrites port 0 if test port is set`() {
val captured = RequestCapturingClient() val captured = RequestCapturingClient()
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured, mapOf()) val client =
RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured, mapOf(), listOf())
val request = HttpRequest.newBuilder(URI("https://example.com:0")).build() val request = HttpRequest.newBuilder(URI("https://example.com:0")).build()
client.send(request, BodyHandlers.discarding()) client.send(request, BodyHandlers.discarding())
@@ -303,9 +307,85 @@ class RequestRewritingClientTest {
private fun rewrittenRequest(uri: String, rules: Map<URI, URI>): String { private fun rewrittenRequest(uri: String, rules: Map<URI, URI>): String {
val captured = RequestCapturingClient() val captured = RequestCapturingClient()
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), -1, captured, rules) val client =
RequestRewritingClient("Pkl", Duration.ofSeconds(42), -1, captured, rules, listOf())
val request = HttpRequest.newBuilder(URI(uri)).build() val request = HttpRequest.newBuilder(URI(uri)).build()
client.send(request, BodyHandlers.discarding()) client.send(request, BodyHandlers.discarding())
return captured.request.uri().toString() return captured.request.uri().toString()
} }
@Test
fun `adds configured headers for matching URI patterns`() {
val captured = RequestCapturingClient()
val client =
RequestRewritingClient(
"Pkl",
Duration.ofSeconds(42),
-1,
captured,
mapOf(),
listOf(
PPair(Pattern.compile("^https://example\\.com/.*"), listOf(PPair("x-one", "one"))),
PPair(
Pattern.compile("^https://example\\.com/foo/.*"),
listOf(PPair("x-two", "two-a"), PPair("x-two", "two-b")),
),
),
)
val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).build()
client.send(request, BodyHandlers.discarding())
assertThatList(captured.request.headers().allValues("x-one")).containsExactly("one")
assertThatList(captured.request.headers().allValues("x-two")).containsExactly("two-a", "two-b")
}
@Test
fun `does not add configured headers for non-matching URI patterns`() {
val captured = RequestCapturingClient()
val client =
RequestRewritingClient(
"Pkl",
Duration.ofSeconds(42),
-1,
captured,
mapOf(),
listOf(
PPair(Pattern.compile("^https://foo\\.com/.*"), listOf(PPair("x-foo", "foo"))),
PPair(Pattern.compile("^https://bar\\.com/.*"), listOf(PPair("x-bar", "bar"))),
),
)
val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.headers().firstValue("x-foo")).isEmpty
assertThat(captured.request.headers().firstValue("x-bar")).isEmpty
}
@Test
fun `appends configured header values to existing request headers`() {
val captured = RequestCapturingClient()
val client =
RequestRewritingClient(
"Pkl",
Duration.ofSeconds(42),
-1,
captured,
mapOf(),
listOf(
PPair(
Pattern.compile("^https://example\\.com/.*"),
listOf(PPair("x-foo", "rule-a"), PPair("x-foo", "rule-b")),
)
),
)
val request =
HttpRequest.newBuilder(URI("https://example.com/foo/bar")).header("x-foo", "request").build()
client.send(request, BodyHandlers.discarding())
assertThatList(captured.request.headers().allValues("x-foo"))
.containsExactly("request", "rule-a", "rule-b")
}
} }
@@ -26,8 +26,10 @@ import org.pkl.commons.writeString
import org.pkl.core.Evaluator import org.pkl.core.Evaluator
import org.pkl.core.ModuleSource import org.pkl.core.ModuleSource
import org.pkl.core.PObject import org.pkl.core.PObject
import org.pkl.core.Pair as PPair
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings import org.pkl.core.evaluatorSettings.PklEvaluatorSettings
import org.pkl.core.settings.PklSettings.Editor import org.pkl.core.settings.PklSettings.Editor
import org.pkl.core.util.GlobResolver
class PklSettingsTest { class PklSettingsTest {
@Test @Test
@@ -64,6 +66,11 @@ class PklSettingsTest {
rewrites { rewrites {
["https://foo.com/"] = "https://bar.com/" ["https://foo.com/"] = "https://bar.com/"
} }
headers {
["https://foo.com/"] {
["x-foo"] = "bar"
}
}
} }
""" """
.trimIndent() .trimIndent()
@@ -77,7 +84,11 @@ class PklSettingsTest {
listOf("example.com", "pkg.pkl-lang.org"), listOf("example.com", "pkg.pkl-lang.org"),
), ),
mapOf(URI("https://foo.com/") to URI("https://bar.com/")), mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
listOf(
PPair(GlobResolver.toRegexPattern("https://foo.com/"), listOf(PPair("x-foo", "bar")))
),
) )
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
} }
@@ -102,6 +113,7 @@ class PklSettingsTest {
PklEvaluatorSettings.Http( PklEvaluatorSettings.Http(
PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()), PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()),
null, null,
null,
) )
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
} }
@@ -48,6 +48,7 @@ import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.TaskAction;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.pkl.commons.cli.CliBaseOptions; import org.pkl.commons.cli.CliBaseOptions;
import org.pkl.core.Pair;
import org.pkl.core.evaluatorSettings.Color; import org.pkl.core.evaluatorSettings.Color;
import org.pkl.gradle.utils.PluginUtils; import org.pkl.gradle.utils.PluginUtils;
@@ -161,6 +162,10 @@ public abstract class BasePklTask extends DefaultTask {
@Optional @Optional
public abstract MapProperty<URI, URI> getHttpRewrites(); public abstract MapProperty<URI, URI> getHttpRewrites();
@Input
@Optional
public abstract ListProperty<Pair<Pattern, List<Pair<String, String>>>> getHttpHeaders();
@Input @Input
@Optional @Optional
public abstract Property<Boolean> getPowerAssertions(); public abstract Property<Boolean> getPowerAssertions();
@@ -218,6 +223,7 @@ public abstract class BasePklTask extends DefaultTask {
getHttpProxy().getOrNull(), getHttpProxy().getOrNull(),
getHttpNoProxy().getOrElse(List.of()), getHttpNoProxy().getOrElse(List.of()),
getHttpRewrites().getOrNull(), getHttpRewrites().getOrNull(),
getHttpHeaders().getOrNull(),
Map.of(), Map.of(),
Map.of(), Map.of(),
null, null,
@@ -164,6 +164,7 @@ public abstract class ModulesTask extends BasePklTask {
null, null,
List.of(), List.of(),
getHttpRewrites().getOrNull(), getHttpRewrites().getOrNull(),
getHttpHeaders().getOrNull(),
Map.of(), Map.of(),
Map.of(), Map.of(),
null, null,
+55
View File
@@ -128,6 +128,9 @@ local const hasNonEmptyHostname = (it: String) ->
@Since { version = "0.29.0" } @Since { version = "0.29.0" }
typealias HttpRewrite = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) typealias HttpRewrite = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname)
@Since { version = "0.32.0" }
typealias UrlPattern = String(endsWith(Regex("[/*]")))
/// Settings that control how Pkl talks to HTTP(S) servers. /// Settings that control how Pkl talks to HTTP(S) servers.
class Http { class Http {
/// Configuration of the HTTP proxy to use. /// Configuration of the HTTP proxy to use.
@@ -169,6 +172,10 @@ class Http {
/// (not schematically enforced). /// (not schematically enforced).
@Since { version = "0.29.0" } @Since { version = "0.29.0" }
rewrites: Mapping<HttpRewrite, HttpRewrite>? rewrites: Mapping<HttpRewrite, HttpRewrite>?
/// HTTP headers to add to outbound requests targeting specified URLs.
@Since { version = "0.32.0" }
headers: Mapping<UrlPattern, Mapping<HttpHeaderName, *Listing<HttpHeaderValue> | HttpHeaderValue>>?
} }
/// Settings that control how Pkl talks to HTTP proxies. /// Settings that control how Pkl talks to HTTP proxies.
@@ -235,3 +242,51 @@ class ExternalReader {
/// Additional command line arguments passed to the external reader process. /// Additional command line arguments passed to the external reader process.
arguments: Listing<String>? arguments: Listing<String>?
} }
@Since { version = "0.32.0" }
typealias ReservedHttpHeaderName =
"accept-charset"
| "accept-encoding"
| "connection"
| "content-length"
| "cookie"
| "date"
| "dnt"
| "expect"
| "host"
| "keep-alive"
| "origin"
| "permissions-policy"
| "referer"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "via"
local const ReservedHttpHeaderPrefix = new Listing {
"proxy-"
"sec-"
"access-control-"
}
local const hasReservedHttpHeaderPrefix = (header: String) ->
ReservedHttpHeaderPrefix.any((it) -> header.startsWith(it))
local const httpHeaderNameRegex = Regex("^[a-zA-Z0-9!#\\$%&'*+-.^_`|~]+$")
local const hasValidHttpHeaderName = (header: String) ->
!httpHeaderNameRegex.findMatchesIn(header).isEmpty
@Since { version = "0.32.0" }
typealias HttpHeaderName =
String(
this == toLowerCase(),
!(this is ReservedHttpHeaderName),
!hasReservedHttpHeaderPrefix.apply(this),
hasValidHttpHeaderName,
)
local const httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$")
@Since { version = "0.32.0" }
typealias HttpHeaderValue = String(!httpHeaderValueRegex.findMatchesIn(this).isEmpty)