mirror of
https://github.com/apple/pkl.git
synced 2026-07-02 19:21:41 +02:00
8e2e5e4ba8
* Relax forbidden headers constraints - remove restriction on browser-related headers - allow any glob pattern (no need to end with `/` or `*`, because glob patterns already require users to explicitly declare prefix matches if that's the intention) * Replace `List<Pair<, ...>>`; use `Map<String, ...>` instead * Use glob pattern strings as an API throughout, instead of `Pattern` (e.g. in `HttpClientBuilder`) * Add HTTP headers to message passing API * Add HTTP headers to executor API (introduces `ExecutorSpiOptions4`) * Add tests for Gradle, CLI, and pkl-executor invocations * Improve documentation * Add `isGlobPattern` API to class `String` for in-language validation of http headers * Behavior change: make sure explicitly configured `User-Agent` in `HttpClientBuilder` can be shadowed by headers (allows users to set `--http-header "**=User-Agent: My User Agent"` and for this to be the only user agent). CC @kyokuping
229 lines
7.3 KiB
Kotlin
229 lines
7.3 KiB
Kotlin
/*
|
|
* 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.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
package org.pkl.cli
|
|
|
|
import com.github.ajalt.clikt.core.BadParameterValue
|
|
import com.github.ajalt.clikt.core.CliktError
|
|
import com.github.ajalt.clikt.core.parse
|
|
import java.nio.file.Path
|
|
import kotlin.io.path.createDirectory
|
|
import kotlin.io.path.createSymbolicLinkPointingTo
|
|
import org.assertj.core.api.Assertions.assertThat
|
|
import org.assertj.core.api.AssertionsForClassTypes.assertThatCode
|
|
import org.junit.jupiter.api.Test
|
|
import org.junit.jupiter.api.assertThrows
|
|
import org.junit.jupiter.api.condition.DisabledOnOs
|
|
import org.junit.jupiter.api.condition.OS
|
|
import org.junit.jupiter.api.io.TempDir
|
|
import org.pkl.cli.commands.EvalCommand
|
|
import org.pkl.cli.commands.RootCommand
|
|
import org.pkl.commons.writeString
|
|
import org.pkl.core.Release
|
|
|
|
class CliMainTest {
|
|
private val rootCmd = RootCommand()
|
|
|
|
@Test
|
|
fun `duplicate CLI option produces meaningful error message`(@TempDir tempDir: Path) {
|
|
val inputFile = tempDir.resolve("test.pkl").writeString("").toString()
|
|
|
|
assertThatCode {
|
|
rootCmd.parse(
|
|
arrayOf("eval", "--output-path", "path1", "--output-path", "path2", inputFile)
|
|
)
|
|
}
|
|
.hasMessage("Option cannot be repeated")
|
|
|
|
assertThatCode {
|
|
rootCmd.parse(arrayOf("eval", "-o", "path1", "--output-path", "path2", inputFile))
|
|
}
|
|
.hasMessage("Option cannot be repeated")
|
|
}
|
|
|
|
@Test
|
|
fun `eval requires at least one file`() {
|
|
assertThatCode { rootCmd.parse(arrayOf("eval")) }
|
|
.isInstanceOf(CliktError::class.java)
|
|
.extracting("paramName")
|
|
.isEqualTo("modules")
|
|
}
|
|
|
|
// Can't reliably create symlinks on Windows.
|
|
// Might get errors like "A required privilege is not held by the client".
|
|
@Test
|
|
@DisabledOnOs(OS.WINDOWS)
|
|
fun `output to symlinked directory works`(@TempDir tempDir: Path) {
|
|
val code =
|
|
"""
|
|
x = 3
|
|
|
|
output {
|
|
value = x
|
|
renderer = new JsonRenderer {}
|
|
}
|
|
"""
|
|
.trimIndent()
|
|
val inputFile = tempDir.resolve("test.pkl").writeString(code).toString()
|
|
val outputFile = makeSymdir(tempDir, "out", "linkOut").resolve("test.pkl").toString()
|
|
|
|
assertThatCode { rootCmd.parse(arrayOf("eval", inputFile, "-o", outputFile)) }
|
|
.doesNotThrowAnyException()
|
|
}
|
|
|
|
@Test
|
|
fun `cannot have multiple output with -o or -x`(@TempDir tempDir: Path) {
|
|
val testIn = makeInput(tempDir)
|
|
val testOut = tempDir.resolve("test").toString()
|
|
val error = """Option is mutually exclusive with -o, --output-path and -x, --expression."""
|
|
|
|
assertThatCode { rootCmd.parse(arrayOf("eval", "-m", testOut, "-x", "x", testIn)) }
|
|
.hasMessage(error)
|
|
|
|
assertThatCode { rootCmd.parse(arrayOf("eval", "-m", testOut, "-o", "/tmp/test", testIn)) }
|
|
.hasMessage(error)
|
|
}
|
|
|
|
@Test
|
|
fun `showing version works`() {
|
|
assertThatCode { rootCmd.parse(arrayOf("--version")) }.hasMessage(Release.current().versionInfo)
|
|
}
|
|
|
|
@Test
|
|
fun `file paths get parsed into URIs`(@TempDir tempDir: Path) {
|
|
val cmd = RootCommand()
|
|
cmd.parse(arrayOf("eval", makeInput(tempDir, "my file.txt")))
|
|
|
|
val evalCmd = cmd.registeredSubcommands().filterIsInstance<EvalCommand>().first()
|
|
val modules = evalCmd.baseOptions.baseOptions(evalCmd.modules).normalizedSourceModules
|
|
assertThat(modules).hasSize(1)
|
|
assertThat(modules[0].path).endsWith("my file.txt")
|
|
}
|
|
|
|
@Test
|
|
fun `invalid URIs are not accepted`() {
|
|
val ex = assertThrows<BadParameterValue> { rootCmd.parse(arrayOf("eval", "file:my file.txt")) }
|
|
|
|
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'")
|
|
}
|
|
|
|
@Test
|
|
fun `missing --http-no-proxy flag is null`(@TempDir tempDir: Path) {
|
|
val inputFile = tempDir.resolve("test.pkl").writeString("").toString()
|
|
val command = EvalCommand()
|
|
command.parse(arrayOf(inputFile))
|
|
assertThat(command.baseOptions.noProxy).isNull()
|
|
}
|
|
|
|
private fun makeInput(tempDir: Path, fileName: String = "test.pkl"): String {
|
|
val code = "x = 1"
|
|
return tempDir.resolve(fileName).writeString(code).toString()
|
|
}
|
|
|
|
private fun makeSymdir(baseDir: Path, name: String, linkName: String): Path {
|
|
val dir = baseDir.resolve(name)
|
|
val link = baseDir.resolve(linkName)
|
|
dir.createDirectory()
|
|
link.createSymbolicLinkPointingTo(dir)
|
|
return link
|
|
}
|
|
|
|
@Test
|
|
fun `invalid http header glob pattern`() {
|
|
val ex =
|
|
assertThrows<BadParameterValue> {
|
|
rootCmd.parse(arrayOf("eval", "--http-header", "foo{{}}=bar:baz", "myModule.pkl"))
|
|
}
|
|
assertThat(ex.message)
|
|
.contains("Sub-patterns cannot be nested. To fix, remove or escape the inner `{` character.")
|
|
}
|
|
|
|
@Test
|
|
fun `forbidden http header name`() {
|
|
val ex =
|
|
assertThrows<BadParameterValue> {
|
|
rootCmd.parse(arrayOf("eval", "--http-header", "**=Connection: baz", "myModule.pkl"))
|
|
}
|
|
assertThat(ex.message).contains("HTTP header `Connection` is a reserved header")
|
|
}
|
|
|
|
@Test
|
|
fun `bad http header value`() {
|
|
val ex =
|
|
assertThrows<BadParameterValue> {
|
|
rootCmd.parse(arrayOf("eval", "--http-header", "**=X-Foo:🙃", "myModule.pkl"))
|
|
}
|
|
assertThat(ex.message).contains("HTTP header value `🙃` has invalid syntax")
|
|
}
|
|
|
|
@Test
|
|
fun `multiple headers`() {
|
|
val cmd = RootCommand()
|
|
cmd.parse(
|
|
arrayOf(
|
|
"eval",
|
|
"--http-header",
|
|
"**=X-Foo:Foo",
|
|
"--http-header",
|
|
"**=X-Foo:Foo2",
|
|
"--http-header",
|
|
"**=X-Bar:Bar",
|
|
"--http-header",
|
|
"https://example.com/**=X-Qux:Qux",
|
|
"pkl:base",
|
|
)
|
|
)
|
|
|
|
val evalCmd = cmd.registeredSubcommands().filterIsInstance<EvalCommand>().first()
|
|
assertThat(evalCmd.baseOptions.httpHeaders)
|
|
.isEqualTo(
|
|
mapOf(
|
|
"**" to mapOf("X-Foo" to listOf("Foo", "Foo2"), "X-Bar" to listOf("Bar")),
|
|
"https://example.com/**" to mapOf("X-Qux" to listOf("Qux")),
|
|
)
|
|
)
|
|
}
|
|
}
|