Add support for HTTP rewrites (#1062)

This adds a new configuration option for the HTTP client to replace URI prefixes when making outbound calls.

Follows the design of https://github.com/apple/pkl-evolution/pull/17
This commit is contained in:
Daniel Chao
2025-07-16 15:53:31 -07:00
committed by GitHub
parent fea031a138
commit 99020bb79d
27 changed files with 607 additions and 47 deletions

View File

@@ -2154,6 +2154,9 @@ Optionally, the SHA-256 checksum of the package can also be specified:
Packages can be managed as dependencies within a _project_.
For more details, consult the <<projects,project>> section of the language reference.
Packages can also be downloaded from a mirror.
For more details, consult the <<mirroring_packages>> section of the language reference.
==== Standard Library URI
Example: `+pkl:math+`
@@ -3204,7 +3207,8 @@ This section discusses language features that are generally more relevant to tem
<<reserved-keywords,Reserved Keywords>> +
<<blank-identifiers,Blank Identifiers>> +
<<projects,Projects>> +
<<external-readers,External Readers>>
<<external-readers,External Readers>> +
<<mirroring_packages,Mirroring packages>>
[[meaning-of-new]]
=== Meaning of `new`
@@ -5755,3 +5759,25 @@ To support both schemes during evaluation, both would need to be registered expl
----
$ pkl eval <module> --external-resource-reader ldap=pkl-ldap --external-resource-reader ldaps=pkl-ldap
----
[[mirroring_packages]]
=== Mirroring packages
A package is a shareable archive of modules and resources that are published to the internet.
A package's URI tells two things:
1. The name of the package.
2. Where the package is downloaded from.
For example, given the package name `package://example.com/mypackage@1.0.0`, Pkl will make an HTTPS request to `\https://example.com/mypackage@1.0.0` to fetch package metadata.
In situations where internet access is restricted, a mirror can be set up to allow use of packages that are published to the internet.
To direct Pkl to a mirror, the `--http-rewrite` CLI option (and its equivalent options when using Pkl's other evaluator APIs) must be used.
For example, `--http-rewrite \https://pkg.pkl-lang.org/=\https://my.internal.mirror/` will tell Pkl to download packages from host `my.internal.mirror`.
NOTE: To effectively mirror packages from pkg.pkl-lang.org, there must be two rewrites; one for `\https://pkg.pkl-lang.org/` (where package metadata is downloaded), and one for `\https://github.com/` (where package zip files are downloaded).
NOTE: Pkl does not provide any tooling to run a mirror server.
To fully set up mirroring, an HTTP(s) server will need be running, and which mirrors the same assets byte-for-byte.

View File

@@ -152,3 +152,14 @@ Example: `example.com,169.254.0.0/16` +
Comma separated list of hosts to which all connections should bypass the proxy.
Hosts can be specified by name, IP address, or IP range using https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation[CIDR notation].
====
.--http-rewrite
[%collapsible]
====
Default: (none) +
Example: `\https://pkg.pkl-lang.org/=https://my.internal.mirror/` +
Replace outbound HTTP(S) requests from one URL with another URL.
The left-hand side describes the source prefix, and the right-hand describes the target prefix.
This option is commonly used to enable package mirroring.
The above example will rewrite URL `\https://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.0` to `\https://my.internal.mirror/pkl-k8s/k8s@1.0.0`.
====

View File

@@ -107,3 +107,14 @@ Example: `noProxy = ["example.com", "169.254.0.0/16"]` +
Hosts to which all connections should bypass the proxy.
Hosts can be specified by name, IP address, or IP range using https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation[CIDR notation].
====
.httpRewrites: MapProperty<String, String>
[%collapsible]
====
Default: `null` +
Example: `httpRewrites = [uri("https://pkg.pkl-lang.org/"): uri("https://my.internal.mirror/")]` +
Replace outbound HTTP(S) requests from one URL with another URL.
The left-hand side describes the source prefix, and the right-hand describes the target prefix.
This option is commonly used to enable package mirroring.
The above example will rewrite URL `\https://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.0` to `\https://my.internal.mirror/pkl-k8s/k8s@1.0.0`.
====

View File

@@ -119,6 +119,36 @@ class CliMainTest {
assertThat(ex.message).contains("URI `file:my file.txt` has invalid syntax")
}
@Test
fun `invalid rewrites -- non-HTTP URI`() {
val ex =
assertThrows<BadParameterValue> {
rootCmd.parse(arrayOf("eval", "--http-rewrite", "foo=bar", "mymodule.pkl"))
}
assertThat(ex.message)
.contains("Rewrite rule must start with 'http://' or 'https://', but was 'foo'")
}
@Test
fun `invalid rewrites -- invalid URI`() {
val ex =
assertThrows<BadParameterValue> {
rootCmd.parse(arrayOf("eval", "--http-rewrite", "https://foo bar=baz", "mymodule.pkl"))
}
assertThat(ex.message).contains("Rewrite target `https://foo bar` has invalid syntax")
}
@Test
fun `invalid rewrites -- doesn't end with slash`() {
val ex =
assertThrows<BadParameterValue> {
rootCmd.parse(
arrayOf("eval", "--http-rewrite", "http://foo.com=https://bar.com", "mymodule.pkl")
)
}
assertThat(ex.message).contains("Rewrite rule must end with '/', but was 'http://foo.com'")
}
private fun makeInput(tempDir: Path, fileName: String = "test.pkl"): String {
val code = "x = 1"
return tempDir.resolve(fileName).writeString(code).toString()

View File

@@ -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.
@@ -140,6 +140,9 @@ data class CliBaseOptions(
/** Hostnames, IP addresses, or CIDR blocks to not proxy. */
val httpNoProxy: List<String>? = null,
/** URL prefixes to rewrite. */
val httpRewrites: Map<URI, URI>? = null,
/** External module reader process specs */
val externalModuleReaders: Map<String, ExternalReader> = mapOf(),

View File

@@ -15,6 +15,7 @@
*/
package org.pkl.commons.cli
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.util.regex.Pattern
@@ -166,29 +167,36 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
protected val useColor: Boolean by lazy { cliOptions.color?.hasColor() ?: false }
private val proxyAddress by lazy {
private val proxyAddress: URI? by lazy {
cliOptions.httpProxy
?: project?.evaluatorSettings?.http?.proxy?.address
?: settings.http?.proxy?.address
}
private val noProxy by lazy {
private val noProxy: List<String>? by lazy {
cliOptions.httpNoProxy
?: project?.evaluatorSettings?.http?.proxy?.noProxy
?: settings.http?.proxy?.noProxy
}
private val externalModuleReaders by lazy {
private val httpRewrites: Map<URI, URI>? by lazy {
cliOptions.httpRewrites
?: project?.evaluatorSettings?.http?.rewrites
?: settings.http?.rewrites()
}
private val externalModuleReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(project?.evaluatorSettings?.externalModuleReaders ?: emptyMap()) +
cliOptions.externalModuleReaders
}
private val externalResourceReaders by lazy {
private val externalResourceReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(project?.evaluatorSettings?.externalResourceReaders ?: emptyMap()) +
cliOptions.externalResourceReaders
}
private val externalProcesses by lazy {
private val externalProcesses:
Map<PklEvaluatorSettings.ExternalReader, ExternalReaderProcess> by lazy {
// Share ExternalReaderProcess instances between configured external resource/module readers
// with the same spec. This avoids spawning multiple subprocesses if the same reader implements
// both reader types and/or multiple schemes.
@@ -232,6 +240,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
if ((proxyAddress ?: noProxy) != null) {
setProxy(proxyAddress, noProxy ?: listOf())
}
httpRewrites?.let(::setRewrites)
// 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.

View File

@@ -235,6 +235,39 @@ class BaseOptions : OptionGroup() {
.single()
.split(",")
val httpRewrites: Map<URI, URI> by
option(
names = arrayOf("--http-rewrite"),
metavar = "from=to",
help = "URL prefixes that should be rewritten.",
)
.convert { it ->
val uris = it.split("=", limit = 2)
require(uris.size == 2) { "Rewrites must be in the form of <from>=<to>" }
try {
val (fromSpec, toSpec) = uris
val fromUri = URI(fromSpec).also { IoUtils.validateRewriteRule(it) }
val toUri = URI(toSpec).also { IoUtils.validateRewriteRule(it) }
fromUri to toUri
} catch (e: IllegalArgumentException) {
fail(e.message!!)
} catch (e: URISyntaxException) {
val message = buildString {
append("Rewrite target `${e.input}` has invalid syntax (${e.reason}).")
if (e.index > -1) {
append("\n\n")
append(e.input)
append("\n")
append(" ".repeat(e.index))
append("^")
}
}
fail(message)
}
}
.multiple()
.toMap()
val externalModuleReaders: Map<String, ExternalReader> by
option(
names = arrayOf("--external-module-reader"),
@@ -289,6 +322,7 @@ class BaseOptions : OptionGroup() {
caCertificates = caCertificates,
httpProxy = proxy,
httpNoProxy = noProxy ?: emptyList(),
httpRewrites = httpRewrites.ifEmpty { null },
externalModuleReaders = externalModuleReaders,
externalResourceReaders = externalResourceReaders,
)

View File

@@ -517,6 +517,20 @@ public final class EvaluatorBuilder {
procs.computeIfAbsent(entry.getValue(), ExternalReaderProcess::of)));
}
}
if (settings.http() != null) {
var httpClientBuilder = HttpClient.builder();
if (settings.http().proxy() != null) {
var noProxy = settings.http().proxy().noProxy();
if (noProxy == null) {
noProxy = Collections.emptyList();
}
httpClientBuilder.setProxy(settings.http().proxy().address(), noProxy);
}
if (settings.http().rewrites() != null) {
httpClientBuilder.setRewrites(settings.http().rewrites());
}
setHttpClient(httpClientBuilder.buildLazily());
}
return this;
}

View File

@@ -18,6 +18,8 @@ package org.pkl.core.evaluatorSettings;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@@ -121,15 +123,31 @@ public record PklEvaluatorSettings(
externalResourceReaders);
}
public record Http(@Nullable Proxy proxy) {
public static final Http DEFAULT = new Http(null);
public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) {
public static final Http DEFAULT = new Http(null, Collections.emptyMap());
@SuppressWarnings("unchecked")
public static @Nullable Http parse(@Nullable Value input) {
if (input == null || input instanceof PNull) {
return null;
} else if (input instanceof PObject http) {
var proxy = Proxy.parse((Value) http.getProperty("proxy"));
return proxy == null ? DEFAULT : new Http(proxy);
var rewrites = http.getProperty("rewrites");
if (rewrites instanceof PNull) {
return new Http(proxy, null);
} else {
var parsedRewrites = new HashMap<URI, URI>();
for (var entry : ((Map<String, String>) rewrites).entrySet()) {
var key = entry.getKey();
var value = entry.getValue();
try {
parsedRewrites.put(new URI(key), new URI(value));
} catch (URISyntaxException e) {
throw new PklException(ErrorMessages.create("invalidUri", e.getInput()));
}
}
return new Http(proxy, parsedRewrites);
}
} else {
throw PklBugException.unreachableCode();
}

View File

@@ -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.
@@ -22,6 +22,7 @@ import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import org.pkl.core.util.Nullable;
@@ -118,6 +119,10 @@ public interface HttpClient extends AutoCloseable {
*/
Builder setProxy(@Nullable URI proxyAddress, List<String> noProxy);
Builder setRewrites(Map<URI, URI> rewrites);
Builder addRewrite(URI sourcePrefix, URI targetPrefix);
/**
* Creates a new {@code HttpClient} from the current state of this builder.
*

View File

@@ -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.
@@ -15,13 +15,17 @@
*/
package org.pkl.core.http;
import static org.pkl.core.util.IoUtils.validateRewriteRule;
import java.net.ProxySelector;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.pkl.core.Release;
import org.pkl.core.http.HttpClient.Builder;
@@ -34,6 +38,7 @@ final class HttpClientBuilder implements HttpClient.Builder {
private final List<ByteBuffer> certificateBytes = new ArrayList<>();
private int testPort = -1;
private ProxySelector proxySelector;
private Map<URI, URI> rewrites = new HashMap<>();
HttpClientBuilder() {
var release = Release.current();
@@ -87,6 +92,24 @@ final class HttpClientBuilder implements HttpClient.Builder {
return this;
}
@Override
public Builder setRewrites(Map<URI, URI> rewrites) {
for (var entry : rewrites.entrySet()) {
validateRewriteRule(entry.getKey());
validateRewriteRule(entry.getValue());
}
this.rewrites = new HashMap<>(rewrites);
return this;
}
@Override
public Builder addRewrite(URI sourcePrefix, URI targetPrefix) {
validateRewriteRule(sourcePrefix);
validateRewriteRule(targetPrefix);
this.rewrites.put(sourcePrefix, targetPrefix);
return this;
}
@Override
public HttpClient build() {
return doBuild().get();
@@ -105,7 +128,7 @@ final class HttpClientBuilder implements HttpClient.Builder {
return () -> {
var jdkClient =
new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites);
};
}
}

View File

@@ -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.
@@ -17,13 +17,21 @@ package org.pkl.core.http;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.time.Duration;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.concurrent.ThreadSafe;
import org.pkl.core.PklBugException;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.Nullable;
/**
* An {@code HttpClient} decorator that
@@ -32,6 +40,7 @@ import org.pkl.core.util.HttpUtils;
* <li>overrides the {@code User-Agent} header of {@code HttpRequest}s
* <li>sets a request timeout if none is present
* <li>ensures that {@link #close()} is idempotent.
* <li>rewrites outbound URI prefixes with another prefix.
* </ul>
*
* <p>Both {@code User-Agent} header and default request timeout are configurable through {@link
@@ -44,22 +53,43 @@ final class RequestRewritingClient implements HttpClient {
final Duration requestTimeout;
final int testPort;
final HttpClient delegate;
private final List<Entry<URI, URI>> rewrites;
private final AtomicBoolean closed = new AtomicBoolean();
RequestRewritingClient(
String userAgent, Duration requestTimeout, int testPort, HttpClient delegate) {
String userAgent,
Duration requestTimeout,
int testPort,
HttpClient delegate,
Map<URI, URI> rewrites) {
this.userAgent = userAgent;
this.requestTimeout = requestTimeout;
this.testPort = testPort;
this.delegate = delegate;
this.rewrites =
rewrites.entrySet().stream()
.map((it) -> Map.entry(normalizeRewrite(it.getKey()), normalizeRewrite(it.getValue())))
.sorted(Comparator.comparingInt((it) -> -it.getKey().toString().length()))
.toList();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException {
checkNotClosed(request);
return delegate.send(rewriteRequest(request), responseBodyHandler);
try {
return delegate.send(rewriteRequest(request), responseBodyHandler);
} catch (IOException e) {
var rewrittenUri = rewriteUri(request.uri());
if (rewrittenUri != request.uri()) {
throw new IOException(
e.getMessage()
+ " (request was rewritten: %s -> %s)".formatted(request.uri(), rewrittenUri),
e);
}
throw e;
}
}
@Override
@@ -99,11 +129,91 @@ final class RequestRewritingClient implements HttpClient {
return builder.build();
}
private URI rewriteUri(URI uri) {
if (testPort != -1 && uri.getPort() == 0) {
return HttpUtils.setPort(uri, testPort);
private static boolean notEqualCaseInsensitive(@Nullable String a, @Nullable String b) {
if (a == null || b == null) {
return !Objects.equals(a, b);
}
return uri;
return !a.equalsIgnoreCase(b);
}
// Our docs say to not include query string or fragment in a rewrite rule, but technically they
// are supported.
public static boolean matchesRewriteRule(URI uri, URI rule) {
if (notEqualCaseInsensitive(uri.getScheme(), rule.getScheme())) {
return false;
}
if (!Objects.equals(uri.getUserInfo(), rule.getUserInfo())) {
return false;
}
if (notEqualCaseInsensitive(uri.getHost(), rule.getHost())) {
return false;
}
if (!Objects.equals(uri.getPath(), rule.getPath())) {
if (uri.getPath() != null
&& rule.getPath() != null
&& rule.getQuery() == null
&& rule.getFragment() == null) {
return uri.getPath().startsWith(rule.getPath());
}
return false;
}
if (!Objects.equals(uri.getQuery(), rule.getQuery())) {
if (uri.getQuery() != null && rule.getQuery() != null && rule.getFragment() == null) {
return uri.getQuery().startsWith(rule.getQuery());
}
return false;
}
if (uri.getFragment() != null && rule.getFragment() != null) {
return uri.getFragment().startsWith(rule.getFragment());
}
return Objects.equals(uri.getFragment(), rule.getFragment());
}
private @Nullable Entry<URI, URI> findRewrite(URI uri) {
for (var entry : rewrites) {
if (matchesRewriteRule(uri, entry.getKey())) {
return entry;
}
}
return null;
}
private URI normalizeRewrite(URI uri) {
try {
return new URI(
uri.getScheme().toLowerCase(),
uri.getUserInfo(),
uri.getHost().toLowerCase(),
uri.getPort(),
uri.getPath(),
uri.getQuery(),
uri.getFragment());
} catch (URISyntaxException e) {
// impossible condition, we started from a valid URI to begin with
throw PklBugException.unreachableCode();
}
}
private URI rewriteUri(URI uri) {
var rewrite = findRewrite(uri);
var ret = uri;
if (rewrite != null) {
var normalized = normalizeRewrite(uri);
var fromUri = rewrite.getKey();
var toUri = rewrite.getValue();
var relativePath = fromUri.relativize(normalized);
ret = toUri.resolve(relativePath);
}
if (testPort != -1 && ret.getPort() == 0) {
ret = HttpUtils.setPort(ret, testPort);
}
return ret;
}
private void checkNotClosed(HttpRequest request) {

View File

@@ -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.
@@ -56,6 +56,13 @@ public abstract class AbstractMessagePackEncoder implements MessageEncoder {
packer.packMapHeader(size + (value1 != null ? 1 : 0) + (value2 != null ? 1 : 0));
}
protected void packMapHeader(
int size, @Nullable Object value1, @Nullable Object value2, @Nullable Object value3)
throws IOException {
packer.packMapHeader(
size + (value1 != null ? 1 : 0) + (value2 != null ? 1 : 0) + (value3 != null ? 1 : 0));
}
protected void packMapHeader(
int size,
@Nullable Object value1,

View File

@@ -841,4 +841,17 @@ public final class IoUtils {
throw new URISyntaxException(uri.toString(), ErrorMessages.create("invalidOpaqueFileUri"));
}
}
public static void validateRewriteRule(URI rewrite) {
if (!Objects.equals(rewrite.getScheme(), "http")
&& !Objects.equals(rewrite.getScheme(), "https")) {
throw new IllegalArgumentException(
"Rewrite rule must start with 'http://' or 'https://', but was '%s'".formatted(rewrite));
}
if (!rewrite.toString().endsWith("/")) {
throw new IllegalArgumentException(
"Rewrite rule must end with '/', but was '%s'".formatted(rewrite));
}
}
}

View File

@@ -1,3 +0,0 @@
import "pkl:analyze"
result = analyze.importGraph(Set("http://localhost:0/foo.pkl"))

View File

@@ -1,14 +0,0 @@
Pkl Error
HTTP/1.1 header parser received no bytes
x | result = analyze.importGraph(Set("http://localhost:0/foo.pkl"))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at analyzeInvalidHttpModule#result (file:///$snippetsDir/input/errors/analyzeInvalidHttpModule.pkl)
xxx | text = renderer.renderDocument(value)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (pkl:base)
xxx | bytes = text.encodeToBytes("UTF-8")
^^^^
at pkl.base#Module.output.bytes (pkl:base)

View File

@@ -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.
@@ -27,7 +27,14 @@ import org.junit.jupiter.api.Test
class RequestRewritingClientTest {
private val captured = RequestCapturingClient()
private val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), -1, captured)
private val client =
RequestRewritingClient(
"Pkl",
Duration.ofSeconds(42),
-1,
captured,
mapOf(URI("https://foo/") to URI("https://bar/")),
)
private val exampleUri = URI("https://example.com/foo/bar.html")
private val exampleRequest = HttpRequest.newBuilder(exampleUri).build()
@@ -114,7 +121,7 @@ class RequestRewritingClientTest {
@Test
fun `rewrites port 0 if test port is set`() {
val captured = RequestCapturingClient()
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured)
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured, mapOf())
val request = HttpRequest.newBuilder(URI("https://example.com:0")).build()
client.send(request, BodyHandlers.discarding())
@@ -130,4 +137,175 @@ class RequestRewritingClientTest {
assertThat(captured.request.uri().port).isEqualTo(0)
}
@Test
fun `matches rewrite rule`() {
fun assertThatRewriteMatches(uri: String, rule: String) =
assertThat(RequestRewritingClient.matchesRewriteRule(URI(uri), URI(rule)))
.`as`("$uri matches $rule")
assertThatRewriteMatches("https://www.foo.com/path/to/qux.html", "https://www.foo.com/").isTrue
assertThatRewriteMatches("HTTPS://www.foo.com/path/to/qux.html", "https://www.foo.com/").isTrue
assertThatRewriteMatches("HTTPS://WWW.FOO.COM/path/to/qux.html", "https://www.foo.com/").isTrue
assertThatRewriteMatches("https://www.foo.com/path/to/qux.html", "https://www.foo.com/path/")
.isTrue
assertThatRewriteMatches("https://www.foo.com/path/to/qux.html", "https://www.foo.com/PATH/")
.isFalse
assertThatRewriteMatches("https://www.foo.com", "https://www.foo.com/").isFalse
assertThatRewriteMatches(
"https://www.foo.com/path/to/qux.html?foo&bar",
"https://www.foo.com/path/to/qux.html?foo&bar",
)
.isTrue
assertThatRewriteMatches(
"https://www.foo.com/path/to/qux.html?foo&baz",
"https://www.foo.com/path/to/qux.html?foo&bar",
)
.isFalse
assertThatRewriteMatches(
"https://www.foo.com/path/to/qux.html?foo&bar#qux",
"https://www.foo.com/path/to/qux.html?foo&bar#q",
)
.isTrue
assertThatRewriteMatches(
"https://www.foo.com/path/to/qux.html?foo&bar#qux",
"https://www.foo.com/path/to/qux.html?foo&bar#w",
)
.isFalse
assertThatRewriteMatches(
"https://www.foo.com/path/to/qux.html?foo&bar",
"https://www.foo.com/path/to/qux.html?foo&bar#w",
)
.isFalse
assertThatRewriteMatches("https:///", "https:///").isTrue
assertThatRewriteMatches("https:///", "http:///").isFalse
// userinfo
assertThatRewriteMatches("https://foo@foo.com/", "http://foo.com/").isFalse
assertThatRewriteMatches("https://foo@foo.com/", "http://foo@foo.com/").isFalse
assertThatRewriteMatches("https://foo@foo.com/", "http://FOO@foo.com/").isFalse
}
@Test
fun `rewrites URIs`() {
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
assertThat(
rewrittenRequest(
"https://FOO.COM/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz",
mapOf(URI("https://FOO.COM/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/qux/baz/")),
)
)
.isEqualTo("https://bar.com/qux/baz/bar/baz")
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/qux/baz/")),
)
)
.isEqualTo("https://bar.com/qux/baz/bar/baz")
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz?qux=foo#corge",
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz?qux=foo#corge")
assertThat(
rewrittenRequest(
"https://fooey@foo.com/bar/baz",
mapOf(URI("https://fooey@foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
}
@Test
fun `rewrites URIs - longest rewrite wins`() {
assertThat(
rewrittenRequest(
"https://foo.com/qux/bar/baz",
mapOf(
URI("https://foo.com/") to URI("https://bar.com/"),
URI("https://foo.com/qux") to URI("https://corge.com/"),
),
)
)
.isEqualTo("https://corge.com/bar/baz")
}
@Test
fun `rewrites URIs - hostname is always lowercased`() {
assertThat(
rewrittenRequest(
"https://foo.com/bar/baz",
mapOf(URI("https://FOO.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
assertThat(
rewrittenRequest(
"https://FOO.com/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
}
@Test
fun `rewrites URIs - scheme is always lowercased`() {
assertThat(
rewrittenRequest(
"HTTPS://foo.com/bar/baz",
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
assertThat(
rewrittenRequest(
"https://FOO.com/bar/baz",
mapOf(URI("HTTPS://foo.com/") to URI("HTTPS://bar.com/")),
)
)
.isEqualTo("https://bar.com/bar/baz")
}
private fun rewrittenRequest(uri: String, rules: Map<URI, URI>): String {
val captured = RequestCapturingClient()
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), -1, captured, rules)
val request = HttpRequest.newBuilder(URI(uri)).build()
client.send(request, BodyHandlers.discarding())
return captured.request.uri().toString()
}
}

View File

@@ -61,6 +61,9 @@ class PklSettingsTest {
"pkg.pkl-lang.org"
}
}
rewrites {
["https://foo.com/"] = "https://bar.com/"
}
}
"""
.trimIndent()
@@ -72,7 +75,8 @@ class PklSettingsTest {
PklEvaluatorSettings.Proxy(
URI("http://localhost:8080"),
listOf("example.com", "pkg.pkl-lang.org"),
)
),
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
)
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
}
@@ -95,7 +99,10 @@ class PklSettingsTest {
val settings = PklSettings.loadFromPklHomeDir(tempDir)
val expectedHttp =
PklEvaluatorSettings.Http(PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()))
PklEvaluatorSettings.Http(
PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()),
null,
)
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
}

View File

@@ -473,6 +473,7 @@ public class PklPlugin implements Plugin<Project> {
task.getTestPort().set(spec.getTestPort());
task.getHttpProxy().set(spec.getHttpProxy());
task.getHttpNoProxy().set(spec.getHttpNoProxy());
task.getHttpRewrites().set(spec.getHttpRewrites());
}
private List<File> getTransitiveModules(AnalyzeImportsTask analyzeTask) {

View File

@@ -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.
@@ -57,4 +57,6 @@ public interface BasePklSpec {
Property<URI> getHttpProxy();
ListProperty<String> getHttpNoProxy();
MapProperty<URI, URI> getHttpRewrites();
}

View File

@@ -142,6 +142,10 @@ public abstract class BasePklTask extends DefaultTask {
@Optional
public abstract ListProperty<String> getHttpNoProxy();
@Input
@Optional
public abstract MapProperty<URI, URI> getHttpRewrites();
/**
* There are issues with using native libraries in Gradle plugins. As a workaround for now, make
* Truffle use an un-optimized runtime.
@@ -195,6 +199,7 @@ public abstract class BasePklTask extends DefaultTask {
Collections.emptyList(),
getHttpProxy().getOrNull(),
getHttpNoProxy().getOrElse(List.of()),
getHttpRewrites().getOrNull(),
Map.of(),
Map.of());
}

View File

@@ -163,6 +163,7 @@ public abstract class ModulesTask extends BasePklTask {
Collections.emptyList(),
null,
List.of(),
getHttpRewrites().getOrNull(),
Map.of(),
Map.of());
}

View File

@@ -95,7 +95,13 @@ class ServerMessagePackDecoder(unpacker: MessageUnpacker) : BaseMessagePackDecod
val httpMap = getNullable(this, "http")?.asMapValue()?.map() ?: return null
val proxy = httpMap.unpackProxy()
val caCertificates = getNullable(httpMap, "caCertificates")?.asBinaryValue()?.asByteArray()
return Http(caCertificates, proxy)
val rewrites =
getNullable(httpMap, "rewrites")
?.asMapValue()
?.map()
?.mapKeys { it.key.asStringValue().asString() }
?.mapValues { it.value.asStringValue().asString() }
return Http(caCertificates, proxy, rewrites)
}
private fun Map<Value, Value>.unpackProxy(): Proxy? {

View File

@@ -36,7 +36,7 @@ class ServerMessagePackEncoder(packer: MessagePacker) : BaseMessagePackEncoder(p
}
private fun MessagePacker.packHttp(http: Http) {
packMapHeader(0, http.caCertificates, http.proxy)
packMapHeader(0, http.caCertificates, http.proxy, http.rewrites)
http.caCertificates?.let { packKeyValue("caCertificates", it) }
http.proxy?.let { proxy ->
packString("proxy")
@@ -44,6 +44,14 @@ class ServerMessagePackEncoder(packer: MessagePacker) : BaseMessagePackEncoder(p
packKeyValue("address", proxy.address?.toString())
packKeyValue("noProxy", proxy.noProxy)
}
http.rewrites?.let { rewrites ->
packString("rewrites")
packMapHeader(rewrites.size)
for ((key, value) in rewrites) {
packString(key)
packString(value)
}
}
}
private fun MessagePacker.packDependencies(dependencies: Map<String, Dependency>) {

View File

@@ -56,6 +56,8 @@ data class Http(
val caCertificates: ByteArray?,
/** Proxy settings */
val proxy: Proxy?,
/** HTTP rewrites */
val rewrites: Map<String, String>?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -65,12 +67,13 @@ data class Http(
if (other.caCertificates == null) return false
if (!caCertificates.contentEquals(other.caCertificates)) return false
} else if (other.caCertificates != null) return false
return Objects.equals(proxy, other.proxy)
return Objects.equals(rewrites, other.rewrites) && Objects.equals(proxy, other.proxy)
}
override fun hashCode(): Int {
var result = caCertificates?.contentHashCode() ?: 0
result = 31 * result + (proxy?.hashCode() ?: 0)
result = 31 * result + (rewrites?.hashCode() ?: 0)
return result
}
}

View File

@@ -96,6 +96,7 @@ class ServerMessagePackCodecTest {
Http(
proxy = Proxy(URI("http://foo.com:1234"), listOf("bar", "baz")),
caCertificates = byteArrayOf(1, 2, 3, 4),
rewrites = mapOf("https://foo.com" to "https://bar.com"),
),
externalModuleReaders = mapOf("external" to externalReader, "external2" to externalReader),
externalResourceReaders = mapOf("external" to externalReader),

View File

@@ -109,10 +109,61 @@ externalModuleReaders: Mapping<String, ExternalReader>?
@Since { version = "0.27.0" }
externalResourceReaders: Mapping<String, ExternalReader>?
const local hostnameRegex = Regex(#"https?://([^/?#]*)"#)
const local hasNonEmptyHostname = (it: String) ->
let (hostname = hostnameRegex.findMatchesIn(it).getOrNull(0)?.groups?.getOrNull(1)?.value)
hostname != null && hostname.length > 0
/// A key or value in [Http.rewrites].
@Since { version = "0.29.0" }
typealias HttpRewrite = String(
startsWith(Regex("https?://")),
endsWith("/"),
hasNonEmptyHostname
)
/// Settings that control how Pkl talks to HTTP(S) servers.
class Http {
/// Configuration of the HTTP proxy to use.
proxy: Proxy?
/// Replace outbound requests from one URL with another URL.
///
/// Each key describes the prefix of a request, and each value describes the replacement prefix.
///
/// This can be useful for setting up mirroring of packages, which are fetched over HTTPS.
//
/// In the case of multiple matches, the longest prefix is is used.
///
/// The URL hostname is case-insensitive.
///
/// A replacement only happens once when resolving URLs.
/// Therefore, the first replacement is the final URL that is used for the outbound request.
///
/// In the following example, an original request for `https://pkg.pkl-lang.org/my/pkg@1.0.0` is
/// replaced with `https://my.internal.mirror/my/pkg@1.0.0`.
///
/// This does not affect `3XX` status code redirect following.
///
/// Example:
///
/// ```
/// rewrites {
/// ["https://pkg.pkl-lang.org/"] = "https://my.internal.mirror/"
/// }
/// ```
///
/// Rewrite targets must satisfy the following:
///
/// * It starts with either `http://`, or `https://`.
/// * It ends with `/`.
/// * It has a non-empty hostname.
///
/// An rewrite target should also not contain a query string or fragment component
/// (not schematically enforced).
@Since { version = "0.29.0" }
rewrites: Mapping<HttpRewrite, HttpRewrite>?
}
/// Settings that control how Pkl talks to HTTP proxies.