Skip to content

Commit fdac86b

Browse files
Make Request.toCurl work more like Chrome's 'copy as cURL' (#9112)
* Make Request.toCurl work more like Chrome's 'copy as cURL' Changes: - single quotes - URL first - omit the default method Also change binary detection to use isProbablyUtf8 instead, which is promoted to the main okhttp module. Also replace the BinaryMode enum with a boolean to optionally not include the body. * More aggressive character escaping --------- Co-authored-by: Jesse Wilson <[email protected]>
1 parent fa84a6e commit fdac86b

File tree

9 files changed

+197
-236
lines changed

9 files changed

+197
-236
lines changed

okhttp-logging-interceptor/src/main/kotlin/okhttp3/logging/HttpLoggingInterceptor.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ import okhttp3.ResponseBody
3535
import okhttp3.internal.charsetOrUtf8
3636
import okhttp3.internal.connection.RealCall
3737
import okhttp3.internal.http.promisesBody
38+
import okhttp3.internal.isProbablyUtf8
3839
import okhttp3.internal.platform.Platform
39-
import okhttp3.logging.internal.isProbablyUtf8
4040
import okio.Buffer
4141
import okio.BufferedSink
4242
import okio.BufferedSource
@@ -336,7 +336,7 @@ class HttpLoggingInterceptor
336336
val charset: Charset = requestBody.contentType().charsetOrUtf8()
337337

338338
logger.log("")
339-
if (!buffer.isProbablyUtf8()) {
339+
if (!buffer.isProbablyUtf8(16L)) {
340340
logger.log(
341341
"--> END ${request.method} (binary ${requestBody.contentLength()}-byte body omitted)",
342342
)
@@ -403,7 +403,7 @@ class HttpLoggingInterceptor
403403

404404
val charset: Charset = responseBody.contentType().charsetOrUtf8()
405405

406-
if (!buffer.isProbablyUtf8()) {
406+
if (!buffer.isProbablyUtf8(16L)) {
407407
logger.log("")
408408
logger.log("<-- END HTTP (${totalMs}ms, binary ${buffer.size}-byte body omitted)")
409409
return response

okhttp-logging-interceptor/src/test/java/okhttp3/logging/IsProbablyUtf8Test.kt

Lines changed: 0 additions & 35 deletions
This file was deleted.

okhttp/api/android/okhttp.api

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,6 @@ public abstract interface class okhttp3/Authenticator {
3737
public final class okhttp3/Authenticator$Companion {
3838
}
3939

40-
public final class okhttp3/BinaryMode : java/lang/Enum {
41-
public static final field FILE Lokhttp3/BinaryMode;
42-
public static final field HEX Lokhttp3/BinaryMode;
43-
public static final field OMIT Lokhttp3/BinaryMode;
44-
public static final field STDIN Lokhttp3/BinaryMode;
45-
public static fun getEntries ()Lkotlin/enums/EnumEntries;
46-
public static fun valueOf (Ljava/lang/String;)Lokhttp3/BinaryMode;
47-
public static fun values ()[Lokhttp3/BinaryMode;
48-
}
49-
5040
public final class okhttp3/Cache : java/io/Closeable, java/io/Flushable {
5141
public static final field Companion Lokhttp3/Cache$Companion;
5242
public final fun -deprecated_directory ()Ljava/io/File;
@@ -1030,10 +1020,9 @@ public final class okhttp3/Request {
10301020
public final fun tag ()Ljava/lang/Object;
10311021
public final fun tag (Ljava/lang/Class;)Ljava/lang/Object;
10321022
public final fun tag (Lkotlin/reflect/KClass;)Ljava/lang/Object;
1033-
public final fun toCurl ()Ljava/lang/String;
1034-
public final fun toCurl (Lokhttp3/BinaryMode;)Ljava/lang/String;
1035-
public final fun toCurl (Lokhttp3/BinaryMode;Ljava/lang/String;)Ljava/lang/String;
1036-
public static synthetic fun toCurl$default (Lokhttp3/Request;Lokhttp3/BinaryMode;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String;
1023+
public final fun toCurl ()Ljava/lang/String;
1024+
public final fun toCurl (Z)Ljava/lang/String;
1025+
public static synthetic fun toCurl$default (Lokhttp3/Request;ZILjava/lang/Object;)Ljava/lang/String;
10371026
public fun toString ()Ljava/lang/String;
10381027
public final fun url ()Lokhttp3/HttpUrl;
10391028
}

okhttp/api/jvm/okhttp.api

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,6 @@ public abstract interface class okhttp3/Authenticator {
3737
public final class okhttp3/Authenticator$Companion {
3838
}
3939

40-
public final class okhttp3/BinaryMode : java/lang/Enum {
41-
public static final field FILE Lokhttp3/BinaryMode;
42-
public static final field HEX Lokhttp3/BinaryMode;
43-
public static final field OMIT Lokhttp3/BinaryMode;
44-
public static final field STDIN Lokhttp3/BinaryMode;
45-
public static fun getEntries ()Lkotlin/enums/EnumEntries;
46-
public static fun valueOf (Ljava/lang/String;)Lokhttp3/BinaryMode;
47-
public static fun values ()[Lokhttp3/BinaryMode;
48-
}
49-
5040
public final class okhttp3/Cache : java/io/Closeable, java/io/Flushable {
5141
public static final field Companion Lokhttp3/Cache$Companion;
5242
public final fun -deprecated_directory ()Ljava/io/File;
@@ -1030,9 +1020,8 @@ public final class okhttp3/Request {
10301020
public final fun tag (Ljava/lang/Class;)Ljava/lang/Object;
10311021
public final fun tag (Lkotlin/reflect/KClass;)Ljava/lang/Object;
10321022
public final fun toCurl ()Ljava/lang/String;
1033-
public final fun toCurl (Lokhttp3/BinaryMode;)Ljava/lang/String;
1034-
public final fun toCurl (Lokhttp3/BinaryMode;Ljava/lang/String;)Ljava/lang/String;
1035-
public static synthetic fun toCurl$default (Lokhttp3/Request;Lokhttp3/BinaryMode;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String;
1023+
public final fun toCurl (Z)Ljava/lang/String;
1024+
public static synthetic fun toCurl$default (Lokhttp3/Request;ZILjava/lang/Object;)Ljava/lang/String;
10361025
public fun toString ()Ljava/lang/String;
10371026
public final fun url ()Lokhttp3/HttpUrl;
10381027
}

okhttp/src/commonJvmAndroid/kotlin/okhttp3/BinaryMode.kt

Lines changed: 0 additions & 8 deletions
This file was deleted.

okhttp/src/commonJvmAndroid/kotlin/okhttp3/Request.kt

Lines changed: 35 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
package okhttp3
1717

1818
import java.net.URL
19-
import java.nio.charset.StandardCharsets
2019
import kotlin.reflect.KClass
2120
import kotlin.reflect.cast
2221
import okhttp3.Headers.Companion.headersOf
2322
import okhttp3.HttpUrl.Companion.toHttpUrl
2423
import okhttp3.internal.http.GzipRequestBody
2524
import okhttp3.internal.http.HttpMethod
25+
import okhttp3.internal.isProbablyUtf8
2626
import okhttp3.internal.isSensitiveHeader
2727
import okio.Buffer
2828

@@ -462,104 +462,55 @@ class Request internal constructor(
462462
}
463463

464464
/**
465-
* Returns a cURL command equivalent to this request, useful for debugging and reproducing requests.
465+
* Returns a cURL command equivalent to this request, useful for debugging and reproducing
466+
* requests.
466467
*
467468
* This includes the HTTP method, headers, request body (if present), and URL.
468469
*
469470
* Example:
471+
*
470472
* ```
471-
* curl -X POST -H "Authorization: Bearer token" --data "{\"key\":\"value\"}" "https://example.com/api"
473+
* curl 'https://example.com/api' \
474+
* -X PUT \
475+
* -H 'Authorization: Bearer token' \
476+
* --data '{\"key\":\"value\"}'
472477
* ```
473478
*
474-
* **Note:** This method will write the body
475-
* to a temporary [okio.Buffer] in memory. This may have side effects if the [RequestBody] is streaming
476-
* or can be consumed only once. Calling this method might prevent re-sending the request body later.
477-
*
478-
* @param binaryFileName default file name to use when dumping binary body data to a file (default: `"request_body.bin"`)
479-
* @param binaryMode default mode to use when writing binary body data (default: `"BinaryMode.STDIN"`)
480-
* @return a cURL command string representing this request.
479+
* **Note:** This will consume the request body. This may have side effects if the [RequestBody]
480+
* is streaming or can be consumed only once.
481481
*/
482482
@JvmOverloads
483-
fun toCurl(
484-
binaryMode: BinaryMode = BinaryMode.STDIN,
485-
binaryFileName: String? = "request_body.bin",
486-
): String {
487-
val curl = StringBuilder("curl")
488-
489-
// Add method if not GET
490-
if (method != "GET") {
491-
curl.append(" -X ").append(method)
492-
}
483+
fun toCurl(includeBody: Boolean = true): String =
484+
buildString {
485+
append("curl ${url.toString().shellEscape()}")
493486

494-
// Append headers
495-
for ((name, value) in headers) {
496-
curl
497-
.append(" -H \"")
498-
.append(name)
499-
.append(": ")
500-
.append(value)
501-
.append("\"")
502-
}
503-
504-
// Append body if present
505-
body?.let { requestBody ->
506-
val buffer = Buffer()
507-
requestBody.writeTo(buffer)
508-
509-
// Clone so we can read multiple times without consuming
510-
val peekBuffer = buffer.clone()
511-
val isBinary = isBinaryData(peekBuffer)
512-
513-
if (isBinary) {
514-
when (binaryMode) {
515-
BinaryMode.HEX -> {
516-
curl.append(" --data-binary \"")
517-
val hexBuffer = buffer.clone()
518-
while (!hexBuffer.exhausted()) {
519-
val b = hexBuffer.readByte().toInt() and 0xFF
520-
curl.append("%02x".format(b))
521-
}
522-
curl.append("\"")
523-
}
524-
BinaryMode.FILE -> {
525-
curl.append(" --data-binary @").append(binaryFileName)
526-
}
527-
BinaryMode.STDIN -> {
528-
curl.append(" --data-binary @-")
529-
}
530-
BinaryMode.OMIT -> {
531-
curl.append(" --data-binary \"[binary body omitted]\"")
532-
}
487+
// Add method if not the default.
488+
val defaultMethod =
489+
when {
490+
includeBody && body != null -> "POST"
491+
else -> "GET"
533492
}
534-
} else {
535-
val bodyString = buffer.readString(StandardCharsets.UTF_8)
536-
curl
537-
.append(" --data \"")
538-
.append(bodyString.replace("\"", "\\\""))
539-
.append("\"")
493+
if (method != defaultMethod) {
494+
append(" \\\n -X ${method.shellEscape()}")
540495
}
541-
}
542496

543-
curl.append(" \"").append(url).append("\"")
544-
return curl.toString()
545-
}
497+
// Append headers.
498+
for ((name, value) in headers) {
499+
append(" \\\n -H ${"$name: $value".shellEscape()}")
500+
}
546501

547-
/**
548-
* Detects binary data by checking for non-printable characters in a buffer.
549-
*/
550-
private fun isBinaryData(peekBuffer: Buffer): Boolean {
551-
var totalBytes = 0
552-
var binaryCount = 0
553-
val textSafeBytes = intArrayOf(0x09, 0x0A, 0x0D) // tab, LF, CR
554-
555-
while (!peekBuffer.exhausted() && totalBytes < 4096) { // limit to first 4KB for performance
556-
val b = peekBuffer.readByte().toInt() and 0xFF
557-
if ((b < 0x20 && b !in textSafeBytes) || b > 0x7E) {
558-
binaryCount++
502+
// Append body if present.
503+
if (includeBody && body != null) {
504+
val bodyBuffer = Buffer()
505+
body.writeTo(bodyBuffer)
506+
507+
if (bodyBuffer.isProbablyUtf8()) {
508+
append(" \\\n --data ${bodyBuffer.readUtf8().shellEscape()}")
509+
} else {
510+
append(" \\\n --data-binary ${bodyBuffer.readByteString().hex().shellEscape()}")
511+
}
559512
}
560-
totalBytes++
561513
}
562514

563-
return totalBytes > 0 && binaryCount > totalBytes * 0.1
564-
}
515+
private fun String.shellEscape(): String = "'${replace("'", "'\\''")}'"
565516
}
Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,26 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
package okhttp3.logging.internal
16+
package okhttp3.internal
1717

1818
import java.io.EOFException
19-
import okio.Buffer
19+
import okio.BufferedSource
2020

2121
/**
22-
* Returns true if the body in question probably contains human readable text. Uses a small
23-
* sample of code points to detect unicode control characters commonly used in binary file
22+
* Returns true if the body in question probably contains human-readable text. Uses a small
23+
* sample of code points to detect Unicode control characters commonly used in binary file
2424
* signatures.
25+
*
26+
* @param codePointLimit the number of code points to read in order to make a decision.
2527
*/
26-
fun Buffer.isProbablyUtf8(): Boolean {
28+
internal fun BufferedSource.isProbablyUtf8(codePointLimit: Long = Long.MAX_VALUE): Boolean {
2729
try {
28-
val prefix = Buffer()
29-
val byteCount = size.coerceAtMost(64)
30-
copyTo(prefix, 0, byteCount)
31-
for (i in 0 until 16) {
32-
if (prefix.exhausted()) {
30+
val peek = peek()
31+
for (i in 0 until codePointLimit) {
32+
if (peek.exhausted()) {
3333
break
3434
}
35-
val codePoint = prefix.readUtf8CodePoint()
35+
val codePoint = peek.readUtf8CodePoint()
3636
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) {
3737
return false
3838
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright (C) 2015 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package okhttp3.internal
17+
18+
import assertk.assertThat
19+
import assertk.assertions.isEqualTo
20+
import assertk.assertions.isFalse
21+
import assertk.assertions.isTrue
22+
import okio.Buffer
23+
import okio.Source
24+
import okio.Timeout
25+
import okio.buffer
26+
import org.junit.jupiter.api.Test
27+
28+
class IsProbablyUtf8Test {
29+
@Test fun isProbablyUtf8() {
30+
assertThat(Buffer().isProbablyUtf8(16L)).isTrue()
31+
assertThat(Buffer().writeUtf8("abc").isProbablyUtf8(16L)).isTrue()
32+
assertThat(Buffer().writeUtf8("new\r\nlines").isProbablyUtf8(16L)).isTrue()
33+
assertThat(Buffer().writeUtf8("white\t space").isProbablyUtf8(16L)).isTrue()
34+
assertThat(Buffer().writeUtf8("Слава Україні!").isProbablyUtf8(16L)).isTrue()
35+
assertThat(Buffer().writeByte(0x80).isProbablyUtf8(16L)).isTrue()
36+
assertThat(Buffer().writeByte(0x00).isProbablyUtf8(16L)).isFalse()
37+
assertThat(Buffer().writeByte(0xc0).isProbablyUtf8(16L)).isFalse()
38+
}
39+
40+
@Test fun doesNotConsumeBuffer() {
41+
val buffer = Buffer()
42+
buffer.writeUtf8("hello ".repeat(1024))
43+
assertThat(buffer.isProbablyUtf8(100L)).isTrue()
44+
assertThat(buffer.readUtf8()).isEqualTo("hello ".repeat(1024))
45+
}
46+
47+
/** Confirm [isProbablyUtf8] doesn't attempt to read the entire stream. */
48+
@Test fun doesNotReadEntireSource() {
49+
val unlimitedSource =
50+
object : Source {
51+
override fun read(
52+
sink: Buffer,
53+
byteCount: Long,
54+
): Long {
55+
sink.writeUtf8("a".repeat(byteCount.toInt()))
56+
return byteCount
57+
}
58+
59+
override fun close() {
60+
}
61+
62+
override fun timeout() = Timeout.NONE
63+
}
64+
65+
assertThat(unlimitedSource.buffer().isProbablyUtf8(1L)).isTrue()
66+
assertThat(unlimitedSource.buffer().isProbablyUtf8(1024L)).isTrue()
67+
assertThat(unlimitedSource.buffer().isProbablyUtf8(1024L * 1024L)).isTrue()
68+
}
69+
}

0 commit comments

Comments
 (0)