Skip to content

Commit c5d8705

Browse files
fix: improve RFC 3986 compliant percent-encoding for query strings and path segments
Update `QueryStringBuilder` to properly distinguish between three encoding contexts — query keys, query values, and path segments — per RFC 3986. Previously, only unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`) were left unencoded, causing over-encoding of characters that are safe in query strings and path segments (e.g., `@`, `:`, `?`, `=` in values, etc.). Path parameter strings in `ValueConvert.ToPathParameterString` now use the new `EncodePathSegment` method instead of plain string passthrough, ensuring path segments are correctly encoded per RFC 3986 pchar rules. Key changes: - Add `EncodePathSegment()` public method on `QueryStringBuilder` for RFC 3986 pchar-safe encoding - Introduce `EncodingContext` enum (`QueryKey`, `QueryValue`, `Path`) to differentiate encoding rules - Query values now allow `=`, `:`, `@`, `/`, `?`, and sub-delimiters (except `&`, `+`, `#`) unencoded - Query keys allow the same set minus `=`; path segments allow unreserved + sub-delims + `:` + `@` - `ValueConvert.ToPathParameterString(string)` now encodes path segments via `EncodePathSegment` - Expand test coverage with new cases for path segment encoding, OData-style keys, and `+`/`=` handling 🌿 Generated with Fern
1 parent a1a37b8 commit c5d8705

File tree

9 files changed

+337
-34
lines changed

9 files changed

+337
-34
lines changed

.fern/metadata.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"cliVersion": "4.62.4",
2+
"cliVersion": "4.65.1",
33
"generatorName": "fernapi/fern-csharp-sdk",
4-
"generatorVersion": "2.55.3",
4+
"generatorVersion": "2.58.0",
55
"generatorConfig": {
66
"namespace": "Vapi.Net",
77
"client-class-name": "VapiClient",
@@ -12,6 +12,6 @@
1212
"simplify-object-dictionaries": true,
1313
"use-discriminated-unions": false
1414
},
15-
"originGitCommit": "7353542e713a4907a187bb55b184fb955cb6c5e9",
16-
"sdkVersion": "1.0.0"
15+
"originGitCommit": "46b109d88752307f8952db91eaa642f61c3875b4",
16+
"sdkVersion": "1.0.1"
1717
}

changelog.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
## 1.0.1 - 2026-04-10
2+
* fix: improve RFC 3986 compliant percent-encoding for query strings and path segments
3+
* Update `QueryStringBuilder` to properly distinguish between three encoding
4+
* contexts — query keys, query values, and path segments — per RFC 3986.
5+
* Previously, only unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`)
6+
* were left unencoded, causing over-encoding of characters that are safe in
7+
* query strings and path segments (e.g., `@`, `:`, `?`, `=` in values, etc.).
8+
* Path parameter strings in `ValueConvert.ToPathParameterString` now use the
9+
* new `EncodePathSegment` method instead of plain string passthrough, ensuring
10+
* path segments are correctly encoded per RFC 3986 pchar rules.
11+
* Key changes:
12+
* Add `EncodePathSegment()` public method on `QueryStringBuilder` for RFC 3986 pchar-safe encoding
13+
* Introduce `EncodingContext` enum (`QueryKey`, `QueryValue`, `Path`) to differentiate encoding rules
14+
* Query values now allow `=`, `:`, `@`, `/`, `?`, and sub-delimiters (except `&`, `+`, `#`) unencoded
15+
* Query keys allow the same set minus `=`; path segments allow unreserved + sub-delims + `:` + `@`
16+
* `ValueConvert.ToPathParameterString(string)` now encodes path segments via `EncodePathSegment`
17+
* Expand test coverage with new cases for path segment encoding, OData-style keys, and `+`/`=` handling
18+
* 🌿 Generated with Fern
19+
120
## 1.0.0 - 2026-04-07
221
* The `CallControllerFindAllPaginatedRequest` request class and the associated `CallControllerFindAllPaginatedRequestSortOrder` enum have been removed from the SDK. If your code references either of these types, you will need to update it accordingly. Please refer to the latest API documentation for the replacement request model.
322
* The following public types have been removed: `GenerateStructuredOutputSuggestionsDto`, `UpdateSupabaseCredentialDto`, and the `CreateVoicemailToolDtoType` enum. Additionally, `AnalyticsClient.GetAsync` now returns `WithRawResponseTask<IEnumerable<AnalyticsQueryResult>>` instead of `Task<IEnumerable<AnalyticsQueryResult>>`; callers must update to await `.Data` or use the `.WithRawResponse()` accessor. The `AnalyticsClient` now implements `IAnalyticsClient`.

src/Vapi.Net.Test/Core/QueryStringBuilderTests.cs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public void Build_SpecialCharacters()
4646
Assert.That(
4747
result,
4848
Is.EqualTo(
49-
"?email=test%40example.com&url=https%3A%2F%2Fexample.com%2Fpath%3Fquery%3Dvalue&special=a%2Bb%3Dc%26d"
49+
"?email=test@example.com&url=https://example.com/path?query=value&special=a%2Bb=c%26d"
5050
)
5151
);
5252
}
@@ -159,7 +159,7 @@ public void Build_ReservedCharacters_NotEncoded()
159159

160160
var result = QueryStringBuilder.Build(parameters);
161161

162-
// Unreserved characters: A-Z a-z 0-9 - _ . ~
162+
// Safe query characters include RFC 3986 unreserved + sub-delimiters (except & = +) + : @ /
163163
Assert.That(result, Is.EqualTo("?path=some-path&id=123-456_789.test~value"));
164164
}
165165

@@ -557,4 +557,102 @@ public void Builder_Set_WithCollection_ReplacesAllPreviousValues()
557557
Assert.That(result, Does.Not.EndWith("id=1"));
558558
Assert.That(result, Does.Not.EndWith("id=2"));
559559
}
560+
561+
[Test]
562+
public void EncodePathSegment_UnreservedChars_NotEncoded()
563+
{
564+
var result = QueryStringBuilder.EncodePathSegment("hello-world_test.value~123");
565+
Assert.That(result, Is.EqualTo("hello-world_test.value~123"));
566+
}
567+
568+
[Test]
569+
public void EncodePathSegment_SubDelimiters_NotEncoded()
570+
{
571+
// All sub-delimiters are safe in path segments per RFC 3986
572+
var result = QueryStringBuilder.EncodePathSegment("a!b$c&d'e(f)g*h+i,j;k=l");
573+
Assert.That(result, Is.EqualTo("a!b$c&d'e(f)g*h+i,j;k=l"));
574+
}
575+
576+
[Test]
577+
public void EncodePathSegment_ColonAndAt_NotEncoded()
578+
{
579+
var result = QueryStringBuilder.EncodePathSegment("user@host:8080");
580+
Assert.That(result, Is.EqualTo("user@host:8080"));
581+
}
582+
583+
[Test]
584+
public void EncodePathSegment_SlashAndQuestion_Encoded()
585+
{
586+
// "/" and "?" are NOT part of pchar, so they must be encoded in path segments
587+
var result = QueryStringBuilder.EncodePathSegment("path/with?query");
588+
Assert.That(result, Is.EqualTo("path%2Fwith%3Fquery"));
589+
}
590+
591+
[Test]
592+
public void EncodePathSegment_Space_Encoded()
593+
{
594+
var result = QueryStringBuilder.EncodePathSegment("hello world");
595+
Assert.That(result, Is.EqualTo("hello%20world"));
596+
}
597+
598+
[Test]
599+
public void EncodePathSegment_EmptyAndNull()
600+
{
601+
Assert.That(QueryStringBuilder.EncodePathSegment(""), Is.EqualTo(""));
602+
Assert.That(QueryStringBuilder.EncodePathSegment(null!), Is.Null);
603+
}
604+
605+
[Test]
606+
public void Build_QueryKeyVsValue_DifferentEncoding()
607+
{
608+
// "=" is safe in query values but NOT in query keys
609+
var parameters = new List<KeyValuePair<string, string>>
610+
{
611+
new("key=with=equals", "value=with=equals"),
612+
};
613+
614+
var result = QueryStringBuilder.Build(parameters);
615+
616+
// Key: "=" must be encoded
617+
// Value: "=" is safe (part of query value safe chars)
618+
Assert.That(result, Is.EqualTo("?key%3Dwith%3Dequals=value=with=equals"));
619+
}
620+
621+
[Test]
622+
public void Build_QueryValue_QuestionMarkNotEncoded()
623+
{
624+
// "?" is safe in both query keys and query values per RFC 3986
625+
var parameters = new List<KeyValuePair<string, string>> { new("q?key", "is this?") };
626+
627+
var result = QueryStringBuilder.Build(parameters);
628+
629+
Assert.That(result, Is.EqualTo("?q?key=is%20this?"));
630+
}
631+
632+
[Test]
633+
public void Build_QueryKey_PlusEncoded()
634+
{
635+
// "+" must be encoded in both query keys and query values
636+
var parameters = new List<KeyValuePair<string, string>> { new("a+b", "c+d") };
637+
638+
var result = QueryStringBuilder.Build(parameters);
639+
640+
Assert.That(result, Is.EqualTo("?a%2Bb=c%2Bd"));
641+
}
642+
643+
[Test]
644+
public void Build_ODataFilter_DollarPreserved()
645+
{
646+
// "$" is safe in query keys (sub-delimiter), verifies OData-style parameters work
647+
var parameters = new List<KeyValuePair<string, string>>
648+
{
649+
new("$filter", "status eq 'active'"),
650+
new("$top", "10"),
651+
};
652+
653+
var result = QueryStringBuilder.Build(parameters);
654+
655+
Assert.That(result, Does.Contain("$filter=status%20eq%20'active'"));
656+
Assert.That(result, Does.Contain("$top=10"));
657+
}
560658
}

src/Vapi.Net.Test/Core/RawClientTests/QueryParameterTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public void QueryParameters_SpecialCharacterEscaping()
2727
.Add("space test", "hello world")
2828
.Build();
2929

30-
Assert.That(queryString, Does.Contain("email=bob%2Btest%40example.com"));
30+
Assert.That(queryString, Does.Contain("email=bob%2Btest@example.com"));
3131
Assert.That(queryString, Does.Contain("%25Complete=100"));
3232
Assert.That(queryString, Does.Contain("space%20test=hello%20world"));
3333
}

src/Vapi.Net/Core/Public/Version.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ namespace Vapi.Net;
33
[Serializable]
44
internal class Version
55
{
6-
public const string Current = "1.0.0";
6+
public const string Current = "1.0.1";
77
}

0 commit comments

Comments
 (0)