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
@@ -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_. Packages can be managed as dependencies within a _project_.
For more details, consult the <<projects,project>> section of the language reference. 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 ==== Standard Library URI
Example: `+pkl:math+` Example: `+pkl:math+`
@@ -3204,7 +3207,8 @@ This section discusses language features that are generally more relevant to tem
<<reserved-keywords,Reserved Keywords>> + <<reserved-keywords,Reserved Keywords>> +
<<blank-identifiers,Blank Identifiers>> + <<blank-identifiers,Blank Identifiers>> +
<<projects,Projects>> + <<projects,Projects>> +
<<external-readers,External Readers>> <<external-readers,External Readers>> +
<<mirroring_packages,Mirroring packages>>
[[meaning-of-new]] [[meaning-of-new]]
=== 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 $ 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.
@@ -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. 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]. 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`.
====
@@ -107,3 +107,14 @@ Example: `noProxy = ["example.com", "169.254.0.0/16"]` +
Hosts to which all connections should bypass the proxy. 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]. 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`.
====
@@ -119,6 +119,36 @@ class CliMainTest {
assertThat(ex.message).contains("URI `file:my file.txt` has invalid syntax") 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 { private fun makeInput(tempDir: Path, fileName: String = "test.pkl"): String {
val code = "x = 1" val code = "x = 1"
return tempDir.resolve(fileName).writeString(code).toString() return tempDir.resolve(fileName).writeString(code).toString()
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -140,6 +140,9 @@ data class CliBaseOptions(
/** Hostnames, IP addresses, or CIDR blocks to not proxy. */ /** Hostnames, IP addresses, or CIDR blocks to not proxy. */
val httpNoProxy: List<String>? = null, val httpNoProxy: List<String>? = null,
/** URL prefixes to rewrite. */
val httpRewrites: Map<URI, URI>? = null,
/** External module reader process specs */ /** External module reader process specs */
val externalModuleReaders: Map<String, ExternalReader> = mapOf(), val externalModuleReaders: Map<String, ExternalReader> = mapOf(),
@@ -15,6 +15,7 @@
*/ */
package org.pkl.commons.cli package org.pkl.commons.cli
import java.net.URI
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.util.regex.Pattern 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 } protected val useColor: Boolean by lazy { cliOptions.color?.hasColor() ?: false }
private val proxyAddress by lazy { private val proxyAddress: URI? by lazy {
cliOptions.httpProxy cliOptions.httpProxy
?: project?.evaluatorSettings?.http?.proxy?.address ?: project?.evaluatorSettings?.http?.proxy?.address
?: settings.http?.proxy?.address ?: settings.http?.proxy?.address
} }
private val noProxy by lazy { private val noProxy: List<String>? by lazy {
cliOptions.httpNoProxy cliOptions.httpNoProxy
?: project?.evaluatorSettings?.http?.proxy?.noProxy ?: project?.evaluatorSettings?.http?.proxy?.noProxy
?: settings.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()) + (project?.evaluatorSettings?.externalModuleReaders ?: emptyMap()) +
cliOptions.externalModuleReaders cliOptions.externalModuleReaders
} }
private val externalResourceReaders by lazy { private val externalResourceReaders: Map<String, PklEvaluatorSettings.ExternalReader> by lazy {
(project?.evaluatorSettings?.externalResourceReaders ?: emptyMap()) + (project?.evaluatorSettings?.externalResourceReaders ?: emptyMap()) +
cliOptions.externalResourceReaders 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 // Share ExternalReaderProcess instances between configured external resource/module readers
// with the same spec. This avoids spawning multiple subprocesses if the same reader implements // with the same spec. This avoids spawning multiple subprocesses if the same reader implements
// both reader types and/or multiple schemes. // both reader types and/or multiple schemes.
@@ -232,6 +240,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
if ((proxyAddress ?: noProxy) != null) { if ((proxyAddress ?: noProxy) != null) {
setProxy(proxyAddress, noProxy ?: listOf()) setProxy(proxyAddress, noProxy ?: listOf())
} }
httpRewrites?.let(::setRewrites)
// Lazy building significantly reduces execution time of commands that do minimal work. // Lazy building significantly reduces execution time of commands that do minimal work.
// However, it means that HTTP client initialization errors won't surface until an HTTP // However, it means that HTTP client initialization errors won't surface until an HTTP
// request is made. // request is made.
@@ -235,6 +235,39 @@ class BaseOptions : OptionGroup() {
.single() .single()
.split(",") .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 val externalModuleReaders: Map<String, ExternalReader> by
option( option(
names = arrayOf("--external-module-reader"), names = arrayOf("--external-module-reader"),
@@ -289,6 +322,7 @@ class BaseOptions : OptionGroup() {
caCertificates = caCertificates, caCertificates = caCertificates,
httpProxy = proxy, httpProxy = proxy,
httpNoProxy = noProxy ?: emptyList(), httpNoProxy = noProxy ?: emptyList(),
httpRewrites = httpRewrites.ifEmpty { null },
externalModuleReaders = externalModuleReaders, externalModuleReaders = externalModuleReaders,
externalResourceReaders = externalResourceReaders, externalResourceReaders = externalResourceReaders,
) )
@@ -517,6 +517,20 @@ public final class EvaluatorBuilder {
procs.computeIfAbsent(entry.getValue(), ExternalReaderProcess::of))); 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; return this;
} }
@@ -18,6 +18,8 @@ package org.pkl.core.evaluatorSettings;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
@@ -121,15 +123,31 @@ public record PklEvaluatorSettings(
externalResourceReaders); externalResourceReaders);
} }
public record Http(@Nullable Proxy proxy) { public record Http(@Nullable Proxy proxy, @Nullable Map<URI, URI> rewrites) {
public static final Http DEFAULT = new Http(null); public static final Http DEFAULT = new Http(null, Collections.emptyMap());
@SuppressWarnings("unchecked")
public static @Nullable Http parse(@Nullable Value input) { public static @Nullable Http parse(@Nullable Value input) {
if (input == null || input instanceof PNull) { if (input == null || input instanceof PNull) {
return null; return null;
} else if (input instanceof PObject http) { } else if (input instanceof PObject http) {
var proxy = Proxy.parse((Value) http.getProperty("proxy")); var proxy = Proxy.parse((Value) http.getProperty("proxy"));
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 { } else {
throw PklBugException.unreachableCode(); throw PklBugException.unreachableCode();
} }
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException; import java.net.http.HttpTimeoutException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
@@ -118,6 +119,10 @@ public interface HttpClient extends AutoCloseable {
*/ */
Builder setProxy(@Nullable URI proxyAddress, List<String> noProxy); 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. * Creates a new {@code HttpClient} from the current state of this builder.
* *
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -15,13 +15,17 @@
*/ */
package org.pkl.core.http; package org.pkl.core.http;
import static org.pkl.core.util.IoUtils.validateRewriteRule;
import java.net.ProxySelector; import java.net.ProxySelector;
import java.net.URI; import java.net.URI;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.pkl.core.Release; import org.pkl.core.Release;
import org.pkl.core.http.HttpClient.Builder; 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 final List<ByteBuffer> certificateBytes = new ArrayList<>();
private int testPort = -1; private int testPort = -1;
private ProxySelector proxySelector; private ProxySelector proxySelector;
private Map<URI, URI> rewrites = new HashMap<>();
HttpClientBuilder() { HttpClientBuilder() {
var release = Release.current(); var release = Release.current();
@@ -87,6 +92,24 @@ final class HttpClientBuilder implements HttpClient.Builder {
return this; 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 @Override
public HttpClient build() { public HttpClient build() {
return doBuild().get(); return doBuild().get();
@@ -105,7 +128,7 @@ final class HttpClientBuilder implements HttpClient.Builder {
return () -> { return () -> {
var jdkClient = var jdkClient =
new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector); new JdkHttpClient(certificateFiles, certificateBytes, connectTimeout, proxySelector);
return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient); return new RequestRewritingClient(userAgent, requestTimeout, testPort, jdkClient, rewrites);
}; };
} }
} }
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -17,13 +17,21 @@ package org.pkl.core.http;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler; import java.net.http.HttpResponse.BodyHandler;
import java.time.Duration; 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 java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.concurrent.ThreadSafe; import javax.annotation.concurrent.ThreadSafe;
import org.pkl.core.PklBugException;
import org.pkl.core.util.HttpUtils; import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.Nullable;
/** /**
* An {@code HttpClient} decorator that * 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>overrides the {@code User-Agent} header of {@code HttpRequest}s
* <li>sets a request timeout if none is present * <li>sets a request timeout if none is present
* <li>ensures that {@link #close()} is idempotent. * <li>ensures that {@link #close()} is idempotent.
* <li>rewrites outbound URI prefixes with another prefix.
* </ul> * </ul>
* *
* <p>Both {@code User-Agent} header and default request timeout are configurable through {@link * <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 Duration requestTimeout;
final int testPort; final int testPort;
final HttpClient delegate; final HttpClient delegate;
private final List<Entry<URI, URI>> rewrites;
private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean();
RequestRewritingClient( 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.userAgent = userAgent;
this.requestTimeout = requestTimeout; this.requestTimeout = requestTimeout;
this.testPort = testPort; this.testPort = testPort;
this.delegate = delegate; 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 @Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler) public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException { throws IOException {
checkNotClosed(request); 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 @Override
@@ -99,11 +129,91 @@ final class RequestRewritingClient implements HttpClient {
return builder.build(); return builder.build();
} }
private URI rewriteUri(URI uri) { private static boolean notEqualCaseInsensitive(@Nullable String a, @Nullable String b) {
if (testPort != -1 && uri.getPort() == 0) { if (a == null || b == null) {
return HttpUtils.setPort(uri, testPort); 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) { private void checkNotClosed(HttpRequest request) {
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -56,6 +56,13 @@ public abstract class AbstractMessagePackEncoder implements MessageEncoder {
packer.packMapHeader(size + (value1 != null ? 1 : 0) + (value2 != null ? 1 : 0)); 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( protected void packMapHeader(
int size, int size,
@Nullable Object value1, @Nullable Object value1,
@@ -841,4 +841,17 @@ public final class IoUtils {
throw new URISyntaxException(uri.toString(), ErrorMessages.create("invalidOpaqueFileUri")); 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));
}
}
} }
@@ -1,3 +0,0 @@
import "pkl:analyze"
result = analyze.importGraph(Set("http://localhost:0/foo.pkl"))
@@ -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)
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -27,7 +27,14 @@ import org.junit.jupiter.api.Test
class RequestRewritingClientTest { class RequestRewritingClientTest {
private val captured = RequestCapturingClient() 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 exampleUri = URI("https://example.com/foo/bar.html")
private val exampleRequest = HttpRequest.newBuilder(exampleUri).build() private val exampleRequest = HttpRequest.newBuilder(exampleUri).build()
@@ -114,7 +121,7 @@ class RequestRewritingClientTest {
@Test @Test
fun `rewrites port 0 if test port is set`() { fun `rewrites port 0 if test port is set`() {
val captured = RequestCapturingClient() val captured = RequestCapturingClient()
val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured) val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured, mapOf())
val request = HttpRequest.newBuilder(URI("https://example.com:0")).build() val request = HttpRequest.newBuilder(URI("https://example.com:0")).build()
client.send(request, BodyHandlers.discarding()) client.send(request, BodyHandlers.discarding())
@@ -130,4 +137,175 @@ class RequestRewritingClientTest {
assertThat(captured.request.uri().port).isEqualTo(0) 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()
}
} }
@@ -61,6 +61,9 @@ class PklSettingsTest {
"pkg.pkl-lang.org" "pkg.pkl-lang.org"
} }
} }
rewrites {
["https://foo.com/"] = "https://bar.com/"
}
} }
""" """
.trimIndent() .trimIndent()
@@ -72,7 +75,8 @@ class PklSettingsTest {
PklEvaluatorSettings.Proxy( PklEvaluatorSettings.Proxy(
URI("http://localhost:8080"), URI("http://localhost:8080"),
listOf("example.com", "pkg.pkl-lang.org"), listOf("example.com", "pkg.pkl-lang.org"),
) ),
mapOf(URI("https://foo.com/") to URI("https://bar.com/")),
) )
assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp)) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
} }
@@ -95,7 +99,10 @@ class PklSettingsTest {
val settings = PklSettings.loadFromPklHomeDir(tempDir) val settings = PklSettings.loadFromPklHomeDir(tempDir)
val expectedHttp = 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)) assertThat(settings).isEqualTo(PklSettings(Editor.SYSTEM, expectedHttp))
} }
@@ -473,6 +473,7 @@ public class PklPlugin implements Plugin<Project> {
task.getTestPort().set(spec.getTestPort()); task.getTestPort().set(spec.getTestPort());
task.getHttpProxy().set(spec.getHttpProxy()); task.getHttpProxy().set(spec.getHttpProxy());
task.getHttpNoProxy().set(spec.getHttpNoProxy()); task.getHttpNoProxy().set(spec.getHttpNoProxy());
task.getHttpRewrites().set(spec.getHttpRewrites());
} }
private List<File> getTransitiveModules(AnalyzeImportsTask analyzeTask) { private List<File> getTransitiveModules(AnalyzeImportsTask analyzeTask) {
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -57,4 +57,6 @@ public interface BasePklSpec {
Property<URI> getHttpProxy(); Property<URI> getHttpProxy();
ListProperty<String> getHttpNoProxy(); ListProperty<String> getHttpNoProxy();
MapProperty<URI, URI> getHttpRewrites();
} }
@@ -142,6 +142,10 @@ public abstract class BasePklTask extends DefaultTask {
@Optional @Optional
public abstract ListProperty<String> getHttpNoProxy(); 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 * There are issues with using native libraries in Gradle plugins. As a workaround for now, make
* Truffle use an un-optimized runtime. * Truffle use an un-optimized runtime.
@@ -195,6 +199,7 @@ public abstract class BasePklTask extends DefaultTask {
Collections.emptyList(), Collections.emptyList(),
getHttpProxy().getOrNull(), getHttpProxy().getOrNull(),
getHttpNoProxy().getOrElse(List.of()), getHttpNoProxy().getOrElse(List.of()),
getHttpRewrites().getOrNull(),
Map.of(), Map.of(),
Map.of()); Map.of());
} }
@@ -163,6 +163,7 @@ public abstract class ModulesTask extends BasePklTask {
Collections.emptyList(), Collections.emptyList(),
null, null,
List.of(), List.of(),
getHttpRewrites().getOrNull(),
Map.of(), Map.of(),
Map.of()); Map.of());
} }
@@ -95,7 +95,13 @@ class ServerMessagePackDecoder(unpacker: MessageUnpacker) : BaseMessagePackDecod
val httpMap = getNullable(this, "http")?.asMapValue()?.map() ?: return null val httpMap = getNullable(this, "http")?.asMapValue()?.map() ?: return null
val proxy = httpMap.unpackProxy() val proxy = httpMap.unpackProxy()
val caCertificates = getNullable(httpMap, "caCertificates")?.asBinaryValue()?.asByteArray() 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? { private fun Map<Value, Value>.unpackProxy(): Proxy? {
@@ -36,7 +36,7 @@ class ServerMessagePackEncoder(packer: MessagePacker) : BaseMessagePackEncoder(p
} }
private fun MessagePacker.packHttp(http: Http) { 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.caCertificates?.let { packKeyValue("caCertificates", it) }
http.proxy?.let { proxy -> http.proxy?.let { proxy ->
packString("proxy") packString("proxy")
@@ -44,6 +44,14 @@ class ServerMessagePackEncoder(packer: MessagePacker) : BaseMessagePackEncoder(p
packKeyValue("address", proxy.address?.toString()) packKeyValue("address", proxy.address?.toString())
packKeyValue("noProxy", proxy.noProxy) 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>) { private fun MessagePacker.packDependencies(dependencies: Map<String, Dependency>) {
@@ -56,6 +56,8 @@ data class Http(
val caCertificates: ByteArray?, val caCertificates: ByteArray?,
/** Proxy settings */ /** Proxy settings */
val proxy: Proxy?, val proxy: Proxy?,
/** HTTP rewrites */
val rewrites: Map<String, String>?,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -65,12 +67,13 @@ data class Http(
if (other.caCertificates == null) return false if (other.caCertificates == null) return false
if (!caCertificates.contentEquals(other.caCertificates)) return false if (!caCertificates.contentEquals(other.caCertificates)) return false
} else if (other.caCertificates != null) 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 { override fun hashCode(): Int {
var result = caCertificates?.contentHashCode() ?: 0 var result = caCertificates?.contentHashCode() ?: 0
result = 31 * result + (proxy?.hashCode() ?: 0) result = 31 * result + (proxy?.hashCode() ?: 0)
result = 31 * result + (rewrites?.hashCode() ?: 0)
return result return result
} }
} }
@@ -96,6 +96,7 @@ class ServerMessagePackCodecTest {
Http( Http(
proxy = Proxy(URI("http://foo.com:1234"), listOf("bar", "baz")), proxy = Proxy(URI("http://foo.com:1234"), listOf("bar", "baz")),
caCertificates = byteArrayOf(1, 2, 3, 4), caCertificates = byteArrayOf(1, 2, 3, 4),
rewrites = mapOf("https://foo.com" to "https://bar.com"),
), ),
externalModuleReaders = mapOf("external" to externalReader, "external2" to externalReader), externalModuleReaders = mapOf("external" to externalReader, "external2" to externalReader),
externalResourceReaders = mapOf("external" to externalReader), externalResourceReaders = mapOf("external" to externalReader),
+51
View File
@@ -109,10 +109,61 @@ externalModuleReaders: Mapping<String, ExternalReader>?
@Since { version = "0.27.0" } @Since { version = "0.27.0" }
externalResourceReaders: Mapping<String, ExternalReader>? 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. /// Settings that control how Pkl talks to HTTP(S) servers.
class Http { class Http {
/// Configuration of the HTTP proxy to use. /// Configuration of the HTTP proxy to use.
proxy: Proxy? 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. /// Settings that control how Pkl talks to HTTP proxies.