From 661a5c6397ad4d05ee9f4de1acc699cb04d0065e Mon Sep 17 00:00:00 2001 From: kyokuping Date: Sun, 31 Aug 2025 23:08:34 +0900 Subject: [PATCH 01/13] Add support for customizing HTTP headers --- .../org/pkl/commons/cli/CliBaseOptions.kt | 3 +++ .../kotlin/org/pkl/commons/cli/CliCommand.kt | 5 +++++ .../pkl/commons/cli/commands/BaseOptions.kt | 9 +++++++++ .../PklEvaluatorSettings.java | 20 ++++++++++++------- .../java/org/pkl/core/http/HttpClient.java | 8 ++++++++ .../org/pkl/core/http/HttpClientBuilder.java | 10 +++++++++- .../java/org/pkl/core/http/JdkHttpClient.java | 12 ++++++++--- .../org/pkl/core/settings/PklSettingsTest.kt | 5 +++++ .../java/org/pkl/gradle/task/BasePklTask.java | 5 +++++ .../java/org/pkl/gradle/task/ModulesTask.java | 1 + stdlib/EvaluatorSettings.pkl | 3 +++ 11 files changed, 70 insertions(+), 11 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt index b3d6b314c..527180ede 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt @@ -144,6 +144,9 @@ data class CliBaseOptions( /** URL prefixes to rewrite. */ val httpRewrites: Map? = null, + /** HTTP headers to add to the request. */ + val httpHeaders: Map? = null, + /** External module reader process specs */ val externalModuleReaders: Map = mapOf(), diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt index a59da40a3..52c5392fb 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt @@ -218,6 +218,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites() } + private val httpHeaders: Map? by lazy { + cliOptions.httpHeaders ?: project?.evaluatorSettings?.http?.headers ?: settings.http?.headers + } + protected val externalModuleReaders: Map by lazy { (evaluatorSettings?.externalModuleReaders ?: emptyMap()) + cliOptions.externalModuleReaders } @@ -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. diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index bb17c918f..cbb980c32 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -285,6 +285,14 @@ class BaseOptions : OptionGroup() { .multiple() .toMap() + val httpHeaders: Map by + option( + names = arrayOf("--http-headers"), + metavar = "key=value", + help = "HTTP header to add to the request.", + ) + .associate() + val externalModuleReaders: Map by option( names = arrayOf("--external-module-reader"), @@ -351,6 +359,7 @@ class BaseOptions : OptionGroup() { httpProxy = proxy, httpNoProxy = noProxy, httpRewrites = httpRewrites.ifEmpty { null }, + httpHeaders = httpHeaders.ifEmpty { null }, externalModuleReaders = externalModuleReaders, externalResourceReaders = externalResourceReaders, traceMode = traceMode, diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index bcb196a91..4bf290045 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -126,8 +126,11 @@ public static PklEvaluatorSettings parse( traceMode == null ? null : TraceMode.valueOf(traceMode.toUpperCase())); } - public record Http(@Nullable Proxy proxy, @Nullable Map rewrites) { - public static final Http DEFAULT = new Http(null, Collections.emptyMap()); + public record Http( + @Nullable Proxy proxy, + @Nullable Map rewrites, + @Nullable Map headers) { + public static final Http DEFAULT = new Http(null, Collections.emptyMap(), null); @SuppressWarnings("unchecked") public static @Nullable Http parse(@Nullable Value input) { @@ -136,10 +139,9 @@ public record Http(@Nullable Proxy proxy, @Nullable Map 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(); + HashMap parsedRewrites = null; + if (!(rewrites instanceof PNull)) { + parsedRewrites = new HashMap(); for (var entry : ((Map) rewrites).entrySet()) { var key = entry.getKey(); var value = entry.getValue(); @@ -149,8 +151,12 @@ public record Http(@Nullable Proxy proxy, @Nullable Map rewrites) { throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); } } - return new Http(proxy, parsedRewrites); } + var headers = http.getProperty("headers"); + return new Http( + proxy, + parsedRewrites, + headers instanceof PNull ? null : ((Map) headers)); } else { throw PklBugException.unreachableCode(); } diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java index c0d9c4bd1..d7a2d54f1 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java @@ -150,6 +150,14 @@ interface Builder { */ Builder addRewrite(URI sourcePrefix, URI targetPrefix); + /** + * Sets the HTTP headers for the request, replacing any previously configured headers. + * + *

This method clears all existing headers and replaces them with the contents of the + * provided map. + */ + Builder setHeaders(Map headers); + /** * Creates a new {@code HttpClient} from the current state of this builder. * diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java index 980ca2e20..b50aa669d 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java @@ -39,6 +39,7 @@ final class HttpClientBuilder implements HttpClient.Builder { private int testPort = -1; private ProxySelector proxySelector; private Map rewrites = new HashMap<>(); + private Map headers = new HashMap<>(); HttpClientBuilder() { var release = Release.current(); @@ -110,6 +111,12 @@ public Builder addRewrite(URI sourcePrefix, URI targetPrefix) { return this; } + @Override + public Builder setHeaders(Map headers) { + this.headers = new HashMap<>(headers); + return this; + } + @Override public HttpClient build() { return doBuild().get(); @@ -127,7 +134,8 @@ private Supplier 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); }; } diff --git a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java index 0758830c2..b639cd2bb 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java @@ -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. @@ -41,6 +41,7 @@ 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; @@ -54,6 +55,7 @@ final class JdkHttpClient implements HttpClient { // non-private for testing final java.net.http.HttpClient underlying; + final Map headers; // call java.net.http.HttpClient.close() if available (JDK 21+) private static final MethodHandle closeMethod; @@ -77,7 +79,8 @@ final class JdkHttpClient implements HttpClient { List certificateFiles, List certificateBytes, Duration connectTimeout, - java.net.ProxySelector proxySelector) { + java.net.ProxySelector proxySelector, + Map headers) { underlying = java.net.http.HttpClient.newBuilder() .sslContext(createSslContext(certificateFiles, certificateBytes)) @@ -85,13 +88,16 @@ final class JdkHttpClient implements HttpClient { .proxy(proxySelector) .followRedirects(Redirect.NORMAL) .build(); + this.headers = headers; } @Override public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) throws IOException { try { - return underlying.send(request, responseBodyHandler); + var wrappedRequestBuilder = HttpRequest.newBuilder(request, (name, value) -> true); + this.headers.forEach(wrappedRequestBuilder::header); + return underlying.send(wrappedRequestBuilder.build(), responseBodyHandler); } catch (ConnectException e) { // original exception has no message throw new ConnectException( diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index a82fc3b22..f7b9c8c35 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -64,6 +64,9 @@ class PklSettingsTest { rewrites { ["https://foo.com/"] = "https://bar.com/" } + headers { + ["X-Foo"] = "bar" + } } """ .trimIndent() @@ -77,6 +80,7 @@ class PklSettingsTest { listOf("example.com", "pkg.pkl-lang.org"), ), mapOf(URI("https://foo.com/") to URI("https://bar.com/")), + mapOf("X-Foo" to "bar"), ) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) } @@ -102,6 +106,7 @@ class PklSettingsTest { PklEvaluatorSettings.Http( PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()), null, + null, ) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java index 1a68dbff3..85c02894b 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java @@ -146,6 +146,10 @@ public Provider getEvalRootDirPath() { @Optional public abstract MapProperty getHttpRewrites(); + @Input + @Optional + public abstract MapProperty getHttpHeaders(); + @Input @Optional public abstract Property getPowerAssertions(); @@ -204,6 +208,7 @@ protected CliBaseOptions getCliBaseOptions() { getHttpProxy().getOrNull(), getHttpNoProxy().getOrElse(List.of()), getHttpRewrites().getOrNull(), + getHttpHeaders().getOrNull(), Map.of(), Map.of(), null, diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java index c4ff215e4..d55bdafe6 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java @@ -164,6 +164,7 @@ protected CliBaseOptions getCliBaseOptions() { null, List.of(), getHttpRewrites().getOrNull(), + getHttpHeaders().getOrNull(), Map.of(), Map.of(), null, diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index d79b95e9d..4a1058eec 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -169,6 +169,9 @@ class Http { /// (not schematically enforced). @Since { version = "0.29.0" } rewrites: Mapping? + + /// HTTP headers to add to every request. + headers: Mapping? } /// Settings that control how Pkl talks to HTTP proxies. From 7edd7fcf34387251b3dcd6d61a9b999a57b8e153 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Sat, 6 Sep 2025 23:41:55 +0900 Subject: [PATCH 02/13] Allow customizing headers for different URLs --- .../org/pkl/commons/cli/CliBaseOptions.kt | 3 +- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 2 +- .../pkl/commons/cli/commands/BaseOptions.kt | 43 +++++++++++++++++-- .../PklEvaluatorSettings.java | 24 ++++++++--- .../java/org/pkl/core/http/HttpClient.java | 3 +- .../org/pkl/core/http/HttpClientBuilder.java | 7 +-- .../java/org/pkl/core/http/JdkHttpClient.java | 14 ++++-- .../org/pkl/core/settings/PklSettingsTest.kt | 8 +++- .../java/org/pkl/gradle/task/BasePklTask.java | 3 +- stdlib/EvaluatorSettings.pkl | 4 +- 10 files changed, 88 insertions(+), 23 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt index 527180ede..26f9cae6f 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt @@ -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 @@ -145,7 +146,7 @@ data class CliBaseOptions( val httpRewrites: Map? = null, /** HTTP headers to add to the request. */ - val httpHeaders: Map? = null, + val httpHeaders: Map>>? = null, /** External module reader process specs */ val externalModuleReaders: Map = mapOf(), diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt index 52c5392fb..9b44fb622 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt @@ -218,7 +218,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites() } - private val httpHeaders: Map? by lazy { + private val httpHeaders: Map>>? by lazy { cliOptions.httpHeaders ?: project?.evaluatorSettings?.http?.headers ?: settings.http?.headers } diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index cbb980c32..4edefc4da 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -31,6 +31,7 @@ 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 @@ -285,13 +286,49 @@ class BaseOptions : OptionGroup() { .multiple() .toMap() - val httpHeaders: Map by + val httpHeaders: Map>> by option( names = arrayOf("--http-headers"), - metavar = "key=value", + metavar = "=

:
[,
:
...]", help = "HTTP header to add to the request.", ) - .associate() + .convert { it -> + val (uriStr, headers) = + it.split("=", limit = 2).let { parts -> + require(parts.size == 2) { + "Headers must be in the form of =
:
" + } + parts[0] to parts[1] + } + + try { + val uri = URI(uriStr.trim()) + + val headerPairs = + headers.split(',').map { header -> + val headerParts = header.split(":", limit = 2) + require(headerParts.size == 2) { "Header '$header' is not in 'name:value' format. " } + PPair(headerParts[0], headerParts[1]) + } + 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 by option( diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 4bf290045..2be94e74d 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -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; @@ -129,7 +130,7 @@ public static PklEvaluatorSettings parse( public record Http( @Nullable Proxy proxy, @Nullable Map rewrites, - @Nullable Map headers) { + @Nullable Map>> headers) { public static final Http DEFAULT = new Http(null, Collections.emptyMap(), null); @SuppressWarnings("unchecked") @@ -141,7 +142,7 @@ public record Http( var rewrites = http.getProperty("rewrites"); HashMap parsedRewrites = null; if (!(rewrites instanceof PNull)) { - parsedRewrites = new HashMap(); + parsedRewrites = new HashMap<>(); for (var entry : ((Map) rewrites).entrySet()) { var key = entry.getKey(); var value = entry.getValue(); @@ -153,10 +154,21 @@ public record Http( } } var headers = http.getProperty("headers"); - return new Http( - proxy, - parsedRewrites, - headers instanceof PNull ? null : ((Map) headers)); + HashMap>> parsedHeaders = null; + if (!(headers instanceof PNull)) { + parsedHeaders = new HashMap<>(); + var headersMap = (Map>>) headers; + for (var entry : headersMap.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + try { + parsedHeaders.put(new URI(key), value); + } catch (URISyntaxException e) { + throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); + } + } + } + return new Http(proxy, parsedRewrites, parsedHeaders); } else { throw PklBugException.unreachableCode(); } diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java index d7a2d54f1..41d1faa91 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java @@ -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; /** @@ -156,7 +157,7 @@ interface Builder { *

This method clears all existing headers and replaces them with the contents of the * provided map. */ - Builder setHeaders(Map headers); + Builder setHeaders(Map>> headers); /** * Creates a new {@code HttpClient} from the current state of this builder. diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java index b50aa669d..3935f02a7 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java @@ -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; @@ -39,7 +40,7 @@ final class HttpClientBuilder implements HttpClient.Builder { private int testPort = -1; private ProxySelector proxySelector; private Map rewrites = new HashMap<>(); - private Map headers = new HashMap<>(); + private Map>> headers = new HashMap<>(); HttpClientBuilder() { var release = Release.current(); @@ -112,8 +113,8 @@ public Builder addRewrite(URI sourcePrefix, URI targetPrefix) { } @Override - public Builder setHeaders(Map headers) { - this.headers = new HashMap<>(headers); + public Builder setHeaders(Map>> headers) { + this.headers = headers; return this; } diff --git a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java index b639cd2bb..e4c0ee871 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java @@ -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; @@ -47,6 +48,7 @@ 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; @@ -55,7 +57,7 @@ final class JdkHttpClient implements HttpClient { // non-private for testing final java.net.http.HttpClient underlying; - final Map headers; + final Map>> headers; // call java.net.http.HttpClient.close() if available (JDK 21+) private static final MethodHandle closeMethod; @@ -80,7 +82,7 @@ final class JdkHttpClient implements HttpClient { List certificateBytes, Duration connectTimeout, java.net.ProxySelector proxySelector, - Map headers) { + Map>> headers) { underlying = java.net.http.HttpClient.newBuilder() .sslContext(createSslContext(certificateFiles, certificateBytes)) @@ -96,7 +98,13 @@ public HttpResponse send(HttpRequest request, BodyHandler responseBody throws IOException { try { var wrappedRequestBuilder = HttpRequest.newBuilder(request, (name, value) -> true); - this.headers.forEach(wrappedRequestBuilder::header); + 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 diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index f7b9c8c35..28072b986 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -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 @@ -65,7 +66,9 @@ class PklSettingsTest { ["https://foo.com/"] = "https://bar.com/" } headers { - ["X-Foo"] = "bar" + ["https://foo.com/"] { + Pair("X-Foo", "bar") + } } } """ @@ -80,8 +83,9 @@ class PklSettingsTest { listOf("example.com", "pkg.pkl-lang.org"), ), mapOf(URI("https://foo.com/") to URI("https://bar.com/")), - mapOf("X-Foo" to "bar"), + mapOf(URI("https://foo.com/") to listOf(PPair("X-Foo", "bar"))), ) + assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java index 85c02894b..c6161d6f2 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java @@ -43,6 +43,7 @@ import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.TaskAction; import org.pkl.commons.cli.CliBaseOptions; +import org.pkl.core.Pair; import org.pkl.core.evaluatorSettings.Color; import org.pkl.core.util.LateInit; import org.pkl.core.util.Nullable; @@ -148,7 +149,7 @@ public Provider getEvalRootDirPath() { @Input @Optional - public abstract MapProperty getHttpHeaders(); + public abstract MapProperty>> getHttpHeaders(); @Input @Optional diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 4a1058eec..58c9279ef 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -170,8 +170,8 @@ class Http { @Since { version = "0.29.0" } rewrites: Mapping? - /// HTTP headers to add to every request. - headers: Mapping? + /// HTTP headers to add to outbound requests targeting specified URLs. + headers: Mapping>>? } /// Settings that control how Pkl talks to HTTP proxies. From 857805c7799f4f4a08b31a2e74bfd82f0bd65cfe Mon Sep 17 00:00:00 2001 From: JaeEun Kim <109906379+kyokuping@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:47:23 +0900 Subject: [PATCH 03/13] add `@Since` annotation in pkl option Co-authored-by: Jen Basch --- stdlib/EvaluatorSettings.pkl | 1 + 1 file changed, 1 insertion(+) diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 58c9279ef..6c72318b3 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -171,6 +171,7 @@ class Http { rewrites: Mapping? /// HTTP headers to add to outbound requests targeting specified URLs. + @Since { version = "0.30.0" } headers: Mapping>>? } From a5b6e63f44454b12ef5cb051d6be9da4c2a714c3 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Fri, 19 Sep 2025 15:32:33 +0900 Subject: [PATCH 04/13] validate `header` syntax --- .../pkl/commons/cli/commands/BaseOptions.kt | 15 +++++++++++--- .../PklEvaluatorSettings.java | 20 ++++++++++++++++--- .../org/pkl/core/errorMessages.properties | 6 ++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index 4edefc4da..9b24b2305 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -33,6 +33,7 @@ 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 @@ -304,11 +305,19 @@ class BaseOptions : OptionGroup() { try { val uri = URI(uriStr.trim()) + val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""") val headerPairs = headers.split(',').map { header -> - val headerParts = header.split(":", limit = 2) - require(headerParts.size == 2) { "Header '$header' is not in 'name:value' format. " } - PPair(headerParts[0], headerParts[1]) + 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) { diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 2be94e74d..19eef79d7 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -54,6 +54,10 @@ public record PklEvaluatorSettings( @Nullable Map 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( @@ -159,10 +163,20 @@ public record Http( parsedHeaders = new HashMap<>(); var headersMap = (Map>>) headers; for (var entry : headersMap.entrySet()) { - var key = entry.getKey(); - var value = entry.getValue(); + 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(key), value); + parsedHeaders.put(new URI(uri), pairs); } catch (URISyntaxException e) { throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); } diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index e9a4147ea..f66d2f852 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -1124,3 +1124,9 @@ Option {1}s must not overlap with built-in options. commandFlagInvalidType=\ Option `{0}` with annotation `@{1}` has invalid type `{2}`.\n\ Expected type: `{3}` + +invalidHeaderName=\ +HTTP header name `{0}` has invalid syntax. + +invalidHeaderValue=\ +HTTP header value `{0}` has invalid syntax. From a68daf15318007d0d09e3647a2cfdb8c267154a3 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Fri, 14 Nov 2025 00:36:49 +0900 Subject: [PATCH 05/13] Rename HttpRewrite to HttpPrefix for clarity --- stdlib/EvaluatorSettings.pkl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 6c72318b3..893449970 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -126,7 +126,10 @@ local const hasNonEmptyHostname = (it: String) -> /// A key or value in [Http.rewrites]. @Since { version = "0.29.0" } -typealias HttpRewrite = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) +typealias HttpPrefix = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) + +@Deprecated { since = "0.30.0"; replaceWith = "HttpPrefix" } +typealias HttpRewrite = HttpPrefix /// Settings that control how Pkl talks to HTTP(S) servers. class Http { @@ -168,11 +171,11 @@ class Http { /// An rewrite target should also not contain a query string or fragment component /// (not schematically enforced). @Since { version = "0.29.0" } - rewrites: Mapping? + rewrites: Mapping? /// HTTP headers to add to outbound requests targeting specified URLs. @Since { version = "0.30.0" } - headers: Mapping>>? + headers: Mapping>>? } /// Settings that control how Pkl talks to HTTP proxies. From 4a6063525f64ce246efb24133214d0ca4c63275c Mon Sep 17 00:00:00 2001 From: kyokuping Date: Fri, 14 Nov 2025 00:37:11 +0900 Subject: [PATCH 06/13] Add strict HTTP header validation to EvaluatorSettings --- .../PklEvaluatorSettings.java | 7 ++- stdlib/EvaluatorSettings.pkl | 54 ++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 19eef79d7..9ba201450 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -167,13 +167,12 @@ public record Http( var pairs = entry.getValue(); for (var pair : pairs) { if (!HEADER_NAME_REGEX.matcher(pair.getFirst()).matches()) { - throw new PklException( - ErrorMessages.create("invalidHeaderName", pair.getFirst())); + throw new PklException(ErrorMessages.create("invalidHeaderName", pair.getFirst())); } if (!HEADER_VALUE_REGEX.matcher(pair.getSecond()).matches()) { throw new PklException( - ErrorMessages.create("invalidHeaderValue", pair.getSecond())); - } + ErrorMessages.create("invalidHeaderValue", pair.getSecond())); + } } try { parsedHeaders.put(new URI(uri), pairs); diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 893449970..cfaabaf36 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -175,7 +175,7 @@ class Http { /// HTTP headers to add to outbound requests targeting specified URLs. @Since { version = "0.30.0" } - headers: Mapping>>? + headers: Mapping>>? } /// Settings that control how Pkl talks to HTTP proxies. @@ -242,3 +242,55 @@ class ExternalReader { /// Additional command line arguments passed to the external reader process. arguments: Listing? } + +typealias ReservedHttpHeaderName = + "accept-charset" + | "accept-encoding" + | "access-control-request-headers" + | "access-control-request-method" + | "connection" + | "content-length" + | "cookie" + | "date" + | "dnt" + | "expect" + | "host" + | "keep-alive" + | "origin" + | "permissions-policy" + | "referer" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "via" + +const local ReservedHttpHeaderPrefix = new Listing { + "proxy-" + "sec-" + "access-control-" +} + +const local hasReservedHttpHeaderPrefix = (header: String) -> + ReservedHttpHeaderPrefix.any((it) -> header.startsWith(it)) + +const local httpHeaderNameRegex = Regex("^[a-zA-Z0-9!#\\$%&'*+-.^_`|~]+$") +const local hasValidHttpHeaderName = (header: String) -> + httpHeaderNameRegex.findMatchesIn(header) + +@Since {version = "0.30.0" } +typealias HttpHeaderName = String( + this == toLowerCase(), + !(this is ReservedHttpHeaderName), + !hasReservedHttpHeaderPrefix.apply(this), + hasValidHttpHeaderName +) + +const local httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$") +const local hasValidHttpHeaderValue = (value : String) -> + httpHeaderValueRegex.findMatchesIn(value) + +@Since {version = "0.30.0"} +typealias HttpHeaderValue = String( + hasValidHttpHeaderValue +) From f44e39f92e6354f9714c95cf35a230b70bff9d76 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Tue, 3 Mar 2026 21:52:40 +0900 Subject: [PATCH 07/13] Update implementation based on the SPICE changes - Introduce pattern-based URL matches - Both accept a single and multiple values for header values - Move header append logic to RequestRewritingClient --- .../org/pkl/commons/cli/CliBaseOptions.kt | 2 +- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 2 +- .../pkl/commons/cli/commands/BaseOptions.kt | 20 ++-- .../PklEvaluatorSettings.java | 59 +++++----- .../java/org/pkl/core/http/HttpClient.java | 5 +- .../org/pkl/core/http/HttpClientBuilder.java | 13 +-- .../java/org/pkl/core/http/JdkHttpClient.java | 20 +--- .../pkl/core/http/RequestRewritingClient.java | 23 +++- .../core/http/RequestRewritingClientTest.kt | 86 ++++++++++++++- .../org/pkl/core/settings/PklSettingsTest.kt | 9 +- .../java/org/pkl/gradle/task/BasePklTask.java | 2 +- stdlib/EvaluatorSettings.pkl | 101 +++++++++--------- 12 files changed, 219 insertions(+), 123 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt index 26f9cae6f..f19da557a 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt @@ -146,7 +146,7 @@ data class CliBaseOptions( val httpRewrites: Map? = null, /** HTTP headers to add to the request. */ - val httpHeaders: Map>>? = null, + val httpHeaders: List>>>? = null, /** External module reader process specs */ val externalModuleReaders: Map = mapOf(), diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt index 9b44fb622..8e74fded5 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt @@ -218,7 +218,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites() } - private val httpHeaders: Map>>? by lazy { + private val httpHeaders: List>>>? by lazy { cliOptions.httpHeaders ?: project?.evaluatorSettings?.http?.headers ?: settings.http?.headers } diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index 9b24b2305..0dc0fa8d5 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -33,10 +33,10 @@ 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 +import org.pkl.core.util.GlobResolver import org.pkl.core.util.IoUtils @Suppress("MemberVisibilityCanBePrivate") @@ -95,6 +95,9 @@ class BaseOptions : OptionGroup() { Pair(it.first, ExternalReader(cmd.first(), cmd.drop(1))) } } + + val HEADER_NAME_REGEX = Pattern.compile("^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$") + val HEADER_VALUE_REGEX = Pattern.compile("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$") } private val defaults = CliBaseOptions() @@ -287,14 +290,14 @@ class BaseOptions : OptionGroup() { .multiple() .toMap() - val httpHeaders: Map>> by + val httpHeaders: List>>> by option( names = arrayOf("--http-headers"), - metavar = "=

:
[,
:
...]", + metavar = "=
:
[,
:
...]", help = "HTTP header to add to the request.", ) .convert { it -> - val (uriStr, headers) = + val (stringPattern, headers) = it.split("=", limit = 2).let { parts -> require(parts.size == 2) { "Headers must be in the form of =
:
" @@ -303,7 +306,7 @@ class BaseOptions : OptionGroup() { } try { - val uri = URI(uriStr.trim()) + var pattern = GlobResolver.toRegexPattern(stringPattern) val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""") val headerPairs = @@ -311,15 +314,15 @@ class BaseOptions : OptionGroup() { val (headerName, headerValue) = headerRegex.find(header)?.destructured ?: fail("Header '$header' is not in 'name:value' format.") - require(PklEvaluatorSettings.HEADER_NAME_REGEX.matcher(headerName).matches()) { + require(HEADER_NAME_REGEX.matcher(headerName).matches()) { "HTTP header name '$headerName' has invalid syntax." } - require(PklEvaluatorSettings.HEADER_VALUE_REGEX.matcher(headerValue).matches()) { + require(HEADER_VALUE_REGEX.matcher(headerValue).matches()) { "HTTP header value '$headerValue' has invalid syntax" } PPair(headerName, headerValue) } - uri to headerPairs + PPair(pattern, headerPairs) } catch (e: IllegalArgumentException) { fail(e.message!!) } catch (e: URISyntaxException) { @@ -337,7 +340,6 @@ class BaseOptions : OptionGroup() { } } .multiple() - .toMap() val externalModuleReaders: Map by option( diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 9ba201450..13f3fe314 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -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. @@ -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; @@ -27,6 +28,7 @@ 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; @@ -35,6 +37,8 @@ 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}. */ @@ -54,10 +58,6 @@ public record PklEvaluatorSettings( @Nullable Map 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( @@ -134,7 +134,7 @@ public static PklEvaluatorSettings parse( public record Http( @Nullable Proxy proxy, @Nullable Map rewrites, - @Nullable Map>> headers) { + @Nullable List>>> headers) { public static final Http DEFAULT = new Http(null, Collections.emptyMap(), null); @SuppressWarnings("unchecked") @@ -157,31 +157,36 @@ public record Http( } } } - var headers = http.getProperty("headers"); - HashMap>> parsedHeaders = null; - if (!(headers instanceof PNull)) { - parsedHeaders = new HashMap<>(); - var headersMap = (Map>>) 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())); - } - } + var headerDefs = http.getProperty("headers"); + List>>> parsedHeaderDefs = null; + if (!(headerDefs instanceof PNull)) { + parsedHeaderDefs = new ArrayList<>(); + var headerDefsMap = (Map>) headerDefs; + for (var entry : headerDefsMap.entrySet()) { + var stringPattern = entry.getKey(); + var headersMap = entry.getValue(); try { - parsedHeaders.put(new URI(uri), pairs); - } catch (URISyntaxException e) { - throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); + var urlPattern = GlobResolver.toRegexPattern(stringPattern); + var pairs = + headersMap.entrySet().stream() + .flatMap( + header -> { + var value = header.getValue(); + if (value instanceof List) { + return ((List) 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, parsedHeaders); + return new Http(proxy, parsedRewrites, parsedHeaderDefs); } else { throw PklBugException.unreachableCode(); } diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java index 41d1faa91..352a37896 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java @@ -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. @@ -23,6 +23,7 @@ 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; @@ -157,7 +158,7 @@ interface Builder { *

This method clears all existing headers and replaces them with the contents of the * provided map. */ - Builder setHeaders(Map>> headers); + Builder setHeaders(List>>> headers); /** * Creates a new {@code HttpClient} from the current state of this builder. diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java index 3935f02a7..99e2f8da2 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClientBuilder.java @@ -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. @@ -27,6 +27,7 @@ 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; @@ -40,7 +41,7 @@ final class HttpClientBuilder implements HttpClient.Builder { private int testPort = -1; private ProxySelector proxySelector; private Map rewrites = new HashMap<>(); - private Map>> headers = new HashMap<>(); + private List>>> headers = new ArrayList<>(); HttpClientBuilder() { var release = Release.current(); @@ -113,7 +114,7 @@ public Builder addRewrite(URI sourcePrefix, URI targetPrefix) { } @Override - public Builder setHeaders(Map>> headers) { + public Builder setHeaders(List>>> headers) { this.headers = headers; return this; } @@ -135,9 +136,9 @@ private Supplier doBuild() { this.proxySelector != null ? this.proxySelector : java.net.ProxySelector.getDefault(); return () -> { var jdkClient = - new JdkHttpClient( - certificateFiles, certificateBytes, connectTimeout, proxySelector, headers); - return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites); + new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector); + return new RequestRewritingClient( + userAgent, requestTimeout, testPort, jdkClient, rewrites, headers); }; } } diff --git a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java index e4c0ee871..c22560e40 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java @@ -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. @@ -22,7 +22,6 @@ 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; @@ -42,13 +41,11 @@ 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; @@ -57,7 +54,6 @@ final class JdkHttpClient implements HttpClient { // non-private for testing final java.net.http.HttpClient underlying; - final Map>> headers; // call java.net.http.HttpClient.close() if available (JDK 21+) private static final MethodHandle closeMethod; @@ -81,8 +77,7 @@ final class JdkHttpClient implements HttpClient { List certificateFiles, List certificateBytes, Duration connectTimeout, - java.net.ProxySelector proxySelector, - Map>> headers) { + java.net.ProxySelector proxySelector) { underlying = java.net.http.HttpClient.newBuilder() .sslContext(createSslContext(certificateFiles, certificateBytes)) @@ -90,22 +85,13 @@ final class JdkHttpClient implements HttpClient { .proxy(proxySelector) .followRedirects(Redirect.NORMAL) .build(); - this.headers = headers; } @Override public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) throws IOException { try { - 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); + return underlying.send(request, responseBodyHandler); } catch (ConnectException e) { // original exception has no message throw new ConnectException( diff --git a/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java b/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java index 7ff366a8c..8cf0e6bbf 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java @@ -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. @@ -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; @@ -54,6 +57,7 @@ final class RequestRewritingClient implements HttpClient { final int testPort; final HttpClient delegate; private final List> rewrites; + private final List>>> headers; private final AtomicBoolean closed = new AtomicBoolean(); @@ -62,7 +66,8 @@ final class RequestRewritingClient implements HttpClient { Duration requestTimeout, int testPort, HttpClient delegate, - Map rewrites) { + Map rewrites, + List>>> headers) { this.userAgent = userAgent; this.requestTimeout = requestTimeout; this.testPort = testPort; @@ -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 @@ -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 @@ -216,6 +225,16 @@ private URI rewriteUri(URI uri) { return ret; } + private List> 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( diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt index 77ad41b25..a1176a83c 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt @@ -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. @@ -21,9 +21,11 @@ import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers import java.time.Duration +import java.util.regex.Pattern import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatList import org.junit.jupiter.api.Test +import org.pkl.core.Pair as PPair class RequestRewritingClientTest { private val captured = RequestCapturingClient() @@ -34,6 +36,7 @@ class RequestRewritingClientTest { -1, captured, mapOf(URI("https://foo/") to URI("https://bar/")), + listOf(), ) private val exampleUri = URI("https://example.com/foo/bar.html") private val exampleRequest = HttpRequest.newBuilder(exampleUri).build() @@ -121,7 +124,8 @@ class RequestRewritingClientTest { @Test fun `rewrites port 0 if test port is set`() { 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() client.send(request, BodyHandlers.discarding()) @@ -303,9 +307,85 @@ class RequestRewritingClientTest { private fun rewrittenRequest(uri: String, rules: Map): String { 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() client.send(request, BodyHandlers.discarding()) 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") + } } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index 28072b986..654a334bb 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -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. @@ -29,6 +29,7 @@ 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 +import org.pkl.core.util.GlobResolver class PklSettingsTest { @Test @@ -67,7 +68,7 @@ class PklSettingsTest { } headers { ["https://foo.com/"] { - Pair("X-Foo", "bar") + ["x-foo"] = "bar" } } } @@ -83,7 +84,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"))), + listOf( + PPair(GlobResolver.toRegexPattern("https://foo.com/"), listOf(PPair("x-foo", "bar"))) + ), ) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java index c6161d6f2..e14fd9261 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java @@ -149,7 +149,7 @@ public Provider getEvalRootDirPath() { @Input @Optional - public abstract MapProperty>> getHttpHeaders(); + public abstract ListProperty>>> getHttpHeaders(); @Input @Optional diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index cfaabaf36..5972a5b36 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -126,10 +126,10 @@ local const hasNonEmptyHostname = (it: String) -> /// A key or value in [Http.rewrites]. @Since { version = "0.29.0" } -typealias HttpPrefix = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) +typealias HttpRewrite = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) -@Deprecated { since = "0.30.0"; replaceWith = "HttpPrefix" } -typealias HttpRewrite = HttpPrefix +@Since { version = "0.31.0" } +typealias UrlPattern = String(endsWith(Regex("[/*]"))) /// Settings that control how Pkl talks to HTTP(S) servers. class Http { @@ -171,11 +171,11 @@ class Http { /// An rewrite target should also not contain a query string or fragment component /// (not schematically enforced). @Since { version = "0.29.0" } - rewrites: Mapping? - + rewrites: Mapping? + /// HTTP headers to add to outbound requests targeting specified URLs. - @Since { version = "0.30.0" } - headers: Mapping>>? + @Since { version = "0.31.0" } + headers: Mapping | HttpHeaderValue>>? } /// Settings that control how Pkl talks to HTTP proxies. @@ -243,54 +243,53 @@ class ExternalReader { arguments: Listing? } -typealias ReservedHttpHeaderName = +typealias ReservedHttpHeaderName = "accept-charset" - | "accept-encoding" - | "access-control-request-headers" - | "access-control-request-method" - | "connection" - | "content-length" - | "cookie" - | "date" - | "dnt" - | "expect" - | "host" - | "keep-alive" - | "origin" - | "permissions-policy" - | "referer" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "via" - -const local ReservedHttpHeaderPrefix = new Listing { + | "accept-encoding" + | "access-control-request-headers" + | "access-control-request-method" + | "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-" } -const local hasReservedHttpHeaderPrefix = (header: String) -> +local const hasReservedHttpHeaderPrefix = (header: String) -> ReservedHttpHeaderPrefix.any((it) -> header.startsWith(it)) - -const local httpHeaderNameRegex = Regex("^[a-zA-Z0-9!#\\$%&'*+-.^_`|~]+$") -const local hasValidHttpHeaderName = (header: String) -> - httpHeaderNameRegex.findMatchesIn(header) - -@Since {version = "0.30.0" } -typealias HttpHeaderName = String( - this == toLowerCase(), - !(this is ReservedHttpHeaderName), - !hasReservedHttpHeaderPrefix.apply(this), - hasValidHttpHeaderName -) - -const local httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$") -const local hasValidHttpHeaderValue = (value : String) -> - httpHeaderValueRegex.findMatchesIn(value) - -@Since {version = "0.30.0"} -typealias HttpHeaderValue = String( - hasValidHttpHeaderValue -) + +local const httpHeaderNameRegex = Regex("^[a-zA-Z0-9!#\\$%&'*+-.^_`|~]+$") +local const hasValidHttpHeaderName = (header: String) -> + !httpHeaderNameRegex.findMatchesIn(header).isEmpty + +@Since { version = "0.31.0" } +typealias HttpHeaderName = + String( + (it: String) -> it == toLowerCase(), + (it: String) -> !(it is ReservedHttpHeaderName), + (it: String) -> !hasReservedHttpHeaderPrefix.apply(it), + hasValidHttpHeaderName, + ) + +local const httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$") +local const hasValidHttpHeaderValue = (value: String) -> + !httpHeaderValueRegex.findMatchesIn(value).isEmpty + +@Since { version = "0.31.0" } +typealias HttpHeaderValue = String(hasValidHttpHeaderValue) From fb2f89dd3881c4bc3a4ac86953ac8c72e8a5624d Mon Sep 17 00:00:00 2001 From: kyokuping Date: Mon, 16 Mar 2026 21:56:10 +0900 Subject: [PATCH 08/13] Revert Copyright date change --- pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java index c22560e40..0758830c2 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024 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. From dc628163869b01ab6e53a723c74554bbd2e4a996 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Mon, 16 Mar 2026 21:59:32 +0900 Subject: [PATCH 09/13] Update `@Since` annotations --- stdlib/EvaluatorSettings.pkl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 5972a5b36..197528917 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -128,7 +128,7 @@ local const hasNonEmptyHostname = (it: String) -> @Since { version = "0.29.0" } typealias HttpRewrite = String(startsWith(Regex("https?://")), endsWith("/"), hasNonEmptyHostname) -@Since { version = "0.31.0" } +@Since { version = "0.32.0" } typealias UrlPattern = String(endsWith(Regex("[/*]"))) /// Settings that control how Pkl talks to HTTP(S) servers. @@ -174,7 +174,7 @@ class Http { rewrites: Mapping? /// HTTP headers to add to outbound requests targeting specified URLs. - @Since { version = "0.31.0" } + @Since { version = "0.32.0" } headers: Mapping | HttpHeaderValue>>? } @@ -243,6 +243,7 @@ class ExternalReader { arguments: Listing? } +@Since { version = "0.32.0" } typealias ReservedHttpHeaderName = "accept-charset" | "accept-encoding" @@ -278,7 +279,7 @@ local const httpHeaderNameRegex = Regex("^[a-zA-Z0-9!#\\$%&'*+-.^_`|~]+$") local const hasValidHttpHeaderName = (header: String) -> !httpHeaderNameRegex.findMatchesIn(header).isEmpty -@Since { version = "0.31.0" } +@Since { version = "0.32.0" } typealias HttpHeaderName = String( (it: String) -> it == toLowerCase(), @@ -291,5 +292,5 @@ local const httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$ local const hasValidHttpHeaderValue = (value: String) -> !httpHeaderValueRegex.findMatchesIn(value).isEmpty -@Since { version = "0.31.0" } +@Since { version = "0.32.0" } typealias HttpHeaderValue = String(hasValidHttpHeaderValue) From 0486d28d5bd962bd701ed82d8bcbe8b255b343ab Mon Sep 17 00:00:00 2001 From: kyokuping Date: Mon, 16 Mar 2026 22:04:33 +0900 Subject: [PATCH 10/13] Rewrite lambdas as expressions --- stdlib/EvaluatorSettings.pkl | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 197528917..ded34a4c9 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -282,15 +282,13 @@ local const hasValidHttpHeaderName = (header: String) -> @Since { version = "0.32.0" } typealias HttpHeaderName = String( - (it: String) -> it == toLowerCase(), - (it: String) -> !(it is ReservedHttpHeaderName), - (it: String) -> !hasReservedHttpHeaderPrefix.apply(it), + this == toLowerCase(), + !(this is ReservedHttpHeaderName), + !hasReservedHttpHeaderPrefix.apply(this), hasValidHttpHeaderName, ) local const httpHeaderValueRegex = Regex("^[\\t\\u0020-\\u007E\\u0080-\\u00FF]*$") -local const hasValidHttpHeaderValue = (value: String) -> - !httpHeaderValueRegex.findMatchesIn(value).isEmpty @Since { version = "0.32.0" } -typealias HttpHeaderValue = String(hasValidHttpHeaderValue) +typealias HttpHeaderValue = String(!httpHeaderValueRegex.findMatchesIn(this).isEmpty) From e44d577e5251c5ac6f8b1d08791455297e918ee5 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Mon, 16 Mar 2026 22:55:50 +0900 Subject: [PATCH 11/13] Disallow using commas as header separator --- .../pkl/commons/cli/commands/BaseOptions.kt | 64 ++++++++----------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index 0dc0fa8d5..4adfa5326 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -293,53 +293,39 @@ class BaseOptions : OptionGroup() { val httpHeaders: List>>> by option( names = arrayOf("--http-headers"), - metavar = "=

:
[,
:
...]", + metavar = "=
:
", help = "HTTP header to add to the request.", ) - .convert { it -> - val (stringPattern, headers) = - it.split("=", limit = 2).let { parts -> - require(parts.size == 2) { - "Headers must be in the form of =
:
" - } - parts[0] to parts[1] + .splitPair() + .transformAll { it -> + val headersMap = mutableMapOf>>() + + for ((stringPattern, header) in it) { + val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""") + val (headerName, headerValue) = + headerRegex.find(header)?.destructured + ?: fail("Header '$header' is not in 'name:value' format.") + require(HEADER_NAME_REGEX.matcher(headerName).matches()) { + "HTTP header name '$headerName' has invalid syntax." } + require(HEADER_VALUE_REGEX.matcher(headerValue).matches()) { + "HTTP header value '$headerValue' has invalid syntax" + } + val headerPair = PPair(headerName, headerValue) + val headerPairList = headersMap[stringPattern] + if (headerPairList == null) { + headersMap[stringPattern] = mutableListOf(headerPair) + } else { + headerPairList.add(headerPair) + } + } try { - var pattern = GlobResolver.toRegexPattern(stringPattern) - - 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(HEADER_NAME_REGEX.matcher(headerName).matches()) { - "HTTP header name '$headerName' has invalid syntax." - } - require(HEADER_VALUE_REGEX.matcher(headerValue).matches()) { - "HTTP header value '$headerValue' has invalid syntax" - } - PPair(headerName, headerValue) - } - PPair(pattern, headerPairs) - } catch (e: IllegalArgumentException) { + headersMap.entries.map { PPair(GlobResolver.toRegexPattern(it.key), it.value) } + } catch (e: GlobResolver.InvalidGlobPatternException) { 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() val externalModuleReaders: Map by option( From 67724307a2dd287eed8e52fce097d11e8594b790 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Tue, 17 Mar 2026 00:09:23 +0900 Subject: [PATCH 12/13] Implement strict header validation in CLI --- .../pkl/commons/cli/commands/BaseOptions.kt | 36 +++++----- .../main/java/org/pkl/core/util/IoUtils.java | 69 ++++++++++++++++++- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index 4adfa5326..f4ee5b534 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -300,28 +300,26 @@ class BaseOptions : OptionGroup() { .transformAll { it -> val headersMap = mutableMapOf>>() - for ((stringPattern, header) in it) { - val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""") - val (headerName, headerValue) = - headerRegex.find(header)?.destructured - ?: fail("Header '$header' is not in 'name:value' format.") - require(HEADER_NAME_REGEX.matcher(headerName).matches()) { - "HTTP header name '$headerName' has invalid syntax." - } - require(HEADER_VALUE_REGEX.matcher(headerValue).matches()) { - "HTTP header value '$headerValue' has invalid syntax" - } - val headerPair = PPair(headerName, headerValue) - val headerPairList = headersMap[stringPattern] - if (headerPairList == null) { - headersMap[stringPattern] = mutableListOf(headerPair) - } else { - headerPairList.add(headerPair) + try { + for ((stringPattern, header) in it) { + val headerRegex = Regex("""^(.+?):[ \t]*(.+)$""") + val (headerName, headerValue) = + headerRegex.find(header)?.destructured + ?: fail("Header '$header' is not in 'name:value' format.") + IoUtils.validateHeaderName(headerName) + IoUtils.validateHeaderValue(headerValue) + val headerPair = PPair(headerName, headerValue) + val headerPairList = headersMap[stringPattern] + if (headerPairList == null) { + headersMap[stringPattern] = mutableListOf(headerPair) + } else { + headerPairList.add(headerPair) + } } - } - try { headersMap.entries.map { PPair(GlobResolver.toRegexPattern(it.key), it.value) } + } catch (e: IllegalArgumentException) { + fail(e.message!!) } catch (e: GlobResolver.InvalidGlobPatternException) { fail(e.message!!) } diff --git a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java index 362fa5dda..18fe04aa6 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java +++ b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java @@ -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. @@ -52,6 +52,36 @@ public final class IoUtils { 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", + "access-control-request-headers", + "access-control-request-method", + "connection", + "content-length", + "cookie", + "date", + "dnt", + "expect", + "host", + "keep-alive", + "origin", + "permissions-policy", + "referer", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "via" + }; + + private static final String[] reservedHeaderPrefixs = {"proxy-", "sec-", "access-control-"}; + private IoUtils() {} public static URL toUrl(URI uri) throws IOException { @@ -854,4 +884,41 @@ public static void validateRewriteRule(URI 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(reservedHeaderPrefixs).anyMatch((prefix) -> headerName.startsWith(prefix)); + } + + public static void validateHeaderName(String headerName) { + if (!headerName.equals(headerName.toLowerCase())) { + throw new IllegalArgumentException( + "HTTP header '%s' should be all lowercase".formatted(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)); + } + } } From 1e51d66d0a4fffcd5f628e0c96003777e5fe2f68 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:27:44 +0900 Subject: [PATCH 13/13] Use computeIfAbsent Co-authored-by: Jen Basch --- .../kotlin/org/pkl/commons/cli/commands/BaseOptions.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index f4ee5b534..274955c9a 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -308,13 +308,9 @@ class BaseOptions : OptionGroup() { ?: fail("Header '$header' is not in 'name:value' format.") IoUtils.validateHeaderName(headerName) IoUtils.validateHeaderValue(headerValue) - val headerPair = PPair(headerName, headerValue) - val headerPairList = headersMap[stringPattern] - if (headerPairList == null) { - headersMap[stringPattern] = mutableListOf(headerPair) - } else { - headerPairList.add(headerPair) - } + headersMap + .computeIfAbsent(stringPattern) { mutableListOf() } + .add(PPair(headerName, headerValue)) } headersMap.entries.map { PPair(GlobResolver.toRegexPattern(it.key), it.value) }