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..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 @@ -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 @@ -144,6 +145,9 @@ data class CliBaseOptions( /** URL prefixes to rewrite. */ val httpRewrites: Map? = null, + /** HTTP headers to add to the request. */ + 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 a59da40a3..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,6 +218,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { cliOptions.httpRewrites ?: evaluatorSettings?.http?.rewrites ?: settings.http?.rewrites() } + private val httpHeaders: List>>>? 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..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 @@ -31,10 +31,12 @@ import java.util.regex.Pattern import org.pkl.commons.cli.CliBaseOptions import org.pkl.commons.cli.CliException import org.pkl.commons.shlex +import org.pkl.core.Pair as PPair import org.pkl.core.evaluatorSettings.Color import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader import org.pkl.core.evaluatorSettings.TraceMode import org.pkl.core.runtime.VmUtils +import org.pkl.core.util.GlobResolver import org.pkl.core.util.IoUtils @Suppress("MemberVisibilityCanBePrivate") @@ -93,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() @@ -285,6 +290,37 @@ class BaseOptions : OptionGroup() { .multiple() .toMap() + val httpHeaders: List>>> by + option( + names = arrayOf("--http-headers"), + metavar = "=
:
", + help = "HTTP header to add to the request.", + ) + .splitPair() + .transformAll { it -> + val headersMap = mutableMapOf>>() + + 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) + 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 by option( names = arrayOf("--external-module-reader"), @@ -351,6 +387,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..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,13 +28,17 @@ import java.util.function.BiFunction; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.pkl.core.Duration; import org.pkl.core.PNull; import org.pkl.core.PObject; +import org.pkl.core.Pair; import org.pkl.core.PklBugException; import org.pkl.core.PklException; import org.pkl.core.Value; import org.pkl.core.util.ErrorMessages; +import org.pkl.core.util.GlobResolver; +import org.pkl.core.util.GlobResolver.InvalidGlobPatternException; import org.pkl.core.util.Nullable; /** Java version of {@code pkl.EvaluatorSettings}. */ @@ -126,8 +131,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 List>>> headers) { + public static final Http DEFAULT = new Http(null, Collections.emptyMap(), null); @SuppressWarnings("unchecked") public static @Nullable Http parse(@Nullable Value input) { @@ -136,10 +144,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 +156,37 @@ public record Http(@Nullable Proxy proxy, @Nullable Map rewrites) { throw new PklException(ErrorMessages.create("invalidUri", e.getInput())); } } - return new Http(proxy, parsedRewrites); } + 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 { + 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, 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 c0d9c4bd1..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,7 +23,9 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import javax.net.ssl.SSLContext; +import org.pkl.core.Pair; import org.pkl.core.util.Nullable; /** @@ -150,6 +152,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(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 980ca2e20..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,8 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; +import java.util.regex.Pattern; +import org.pkl.core.Pair; import org.pkl.core.Release; import org.pkl.core.http.HttpClient.Builder; @@ -39,6 +41,7 @@ final class HttpClientBuilder implements HttpClient.Builder { private int testPort = -1; private ProxySelector proxySelector; private Map rewrites = new HashMap<>(); + private List>>> headers = new ArrayList<>(); HttpClientBuilder() { var release = Release.current(); @@ -110,6 +113,12 @@ public Builder addRewrite(URI sourcePrefix, URI targetPrefix) { return this; } + @Override + public Builder setHeaders(List>>> headers) { + this.headers = headers; + return this; + } + @Override public HttpClient build() { return doBuild().get(); @@ -128,7 +137,8 @@ private Supplier doBuild() { return () -> { var jdkClient = new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector); - return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites); + return new RequestRewritingClient( + userAgent, requestTimeout, testPort, jdkClient, rewrites, headers); }; } } 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/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)); + } + } } 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. 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 a82fc3b22..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. @@ -26,8 +26,10 @@ 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 +import org.pkl.core.util.GlobResolver class PklSettingsTest { @Test @@ -64,6 +66,11 @@ class PklSettingsTest { rewrites { ["https://foo.com/"] = "https://bar.com/" } + headers { + ["https://foo.com/"] { + ["x-foo"] = "bar" + } + } } """ .trimIndent() @@ -77,7 +84,11 @@ class PklSettingsTest { listOf("example.com", "pkg.pkl-lang.org"), ), 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)) } @@ -102,6 +113,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..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 @@ -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; @@ -146,6 +147,10 @@ public Provider getEvalRootDirPath() { @Optional public abstract MapProperty getHttpRewrites(); + @Input + @Optional + public abstract ListProperty>>> getHttpHeaders(); + @Input @Optional public abstract Property getPowerAssertions(); @@ -204,6 +209,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..ded34a4c9 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -128,6 +128,9 @@ local const hasNonEmptyHostname = (it: String) -> @Since { version = "0.29.0" } 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. class Http { /// Configuration of the HTTP proxy to use. @@ -169,6 +172,10 @@ class Http { /// (not schematically enforced). @Since { version = "0.29.0" } rewrites: Mapping? + + /// HTTP headers to add to outbound requests targeting specified URLs. + @Since { version = "0.32.0" } + headers: Mapping | HttpHeaderValue>>? } /// Settings that control how Pkl talks to HTTP proxies. @@ -235,3 +242,53 @@ class ExternalReader { /// Additional command line arguments passed to the external reader process. arguments: Listing? } + +@Since { version = "0.32.0" } +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" + +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)