Skip to content

Commit 8a0cc73

Browse files
github-actions[bot]CopilotCopilot
authored
[repo-assist] perf+test: replace UriBuilder+ParseQueryString with StringBuilder; add 9 tests (500→509) (#457)
* perf+test: replace UriBuilder+ParseQueryString with StringBuilder, add 9 tests (500→509) Tasks: 8 (perf), 9 (tests), 5 (coding improvement) perf: RuntimeHelpers.createHttpRequest - Replace UriBuilder + System.Web.HttpUtility.ParseQueryString + NameValueCollection with a single StringBuilder + Uri.EscapeDataString pass. - Avoids 4-5 allocations per API call (UriBuilder, NameValueCollection, Hashtable, intermediate strings). Now allocates only one StringBuilder. - Encoding changes from form-encoding (spaces as '+') to RFC 3986 percent-encoding (spaces as '%20'); both are accepted by HTTP servers. - Removes the Seq.isEmpty early-exit fast path (no longer needed — the StringBuilder path is already allocation-efficient with zero params). - Removes the now-unused System.Web.HttpUtility reference. coding: RuntimeHelpers.toFormUrlEncodedContent - Merge the Seq.filter + Seq.choose passes into a single Seq.choose, avoiding an intermediate sequence allocation for the common case. tests (9 new, 500→509): - RuntimeHelpersTests: 4 tests documenting RFC 3986 percent-encoding in createHttpRequest (spaces, & and = in values, spaces in names). - Schema.OperationCompilationTests: 2 tests asserting that the '200' response takes priority over '201' when both are present in an operation. - Schema.V2SchemaCompilationTests: 4 tests verifying compiled operation return types from a Swagger 2.0 petstore (listPets→Task<Pet[]>, getPet→Task<Pet>, getPet path param int64, createPet→Task<IO.Stream> per OpenApi normalisation). Build/test: - dotnet fantomas --check: no issues - Unit tests: 509 total, 0 failed, 1 skipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * fix: handle existing query and fragment in createHttpRequest --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 92b8ccb commit 8a0cc73

4 files changed

Lines changed: 199 additions & 21 deletions

File tree

src/SwaggerProvider.Runtime/RuntimeHelpers.fs

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -503,14 +503,16 @@ module RuntimeHelpers =
503503
let toFormUrlEncodedContent(keyValues: seq<string * obj>) =
504504
let keyValues =
505505
keyValues
506-
|> Seq.filter(snd >> isNull >> not)
507506
|> Seq.choose(fun (k, v) ->
508-
let param = toParam v
509-
510-
if isNull param then
507+
if isNull v then
511508
None
512509
else
513-
Some(Collections.Generic.KeyValuePair(k, param)))
510+
let param = toParam v
511+
512+
if isNull param then
513+
None
514+
else
515+
Some(Collections.Generic.KeyValuePair(k, param)))
514516

515517
new FormUrlEncodedContent(keyValues)
516518

@@ -556,24 +558,51 @@ module RuntimeHelpers =
556558

557559
let createHttpRequest (httpMethod: string) (address: string) (queryParams: seq<string * string>) =
558560
let requestUrl =
559-
// Fast path: avoid UriBuilder + ParseQueryString allocation when there are no query params.
560-
// TrimStart('/') mirrors the UriBuilder path's PathAndQuery.TrimStart('/') normalisation,
561-
// which strips the leading slash from schema paths such as "/pets" → "pets". A leading-
562-
// slash relative URI resolves from the host root and silently drops any base path, so
563-
// normalisation must be applied on both branches.
564-
if Seq.isEmpty queryParams then
565-
address.TrimStart('/')
566-
else
567-
let fakeHost = "http://fake-host/"
568-
let builder = UriBuilder(combineUrl fakeHost address)
569-
let query = System.Web.HttpUtility.ParseQueryString(builder.Query)
561+
// Build the request URL using a StringBuilder to avoid UriBuilder + ParseQueryString
562+
// allocations (NameValueCollection, internal Hashtable, multiple string copies).
563+
// TrimStart('/') strips the leading slash from schema paths such as "/pets" → "pets"
564+
// so that the relative URI resolves from the HttpClient.BaseAddress path rather than
565+
// the host root.
566+
// Values are RFC 3986 percent-encoded via Uri.EscapeDataString (spaces → %20), which
567+
// is accepted by all standards-compliant HTTP servers.
568+
let address = address.TrimStart('/')
569+
let fragmentStart = address.IndexOf('#')
570+
571+
let baseAddress, fragment =
572+
if fragmentStart >= 0 then
573+
address.Substring(0, fragmentStart), address.Substring(fragmentStart)
574+
else
575+
address, null
576+
577+
let mutable sb = null
578+
579+
for name, value in queryParams do
580+
if not(isNull value) then
581+
if isNull sb then
582+
sb <- Text.StringBuilder(baseAddress)
583+
584+
if baseAddress.Contains("?") then
585+
if sb.Length > 0 then
586+
let last = sb[sb.Length - 1]
587+
588+
if last <> '?' && last <> '&' then
589+
sb.Append('&') |> ignore
590+
else
591+
sb.Append('?') |> ignore
592+
else
593+
sb.Append('&') |> ignore
594+
595+
sb.Append(Uri.EscapeDataString(name)) |> ignore
596+
sb.Append('=') |> ignore
597+
sb.Append(Uri.EscapeDataString(value)) |> ignore
570598

571-
for name, value in queryParams do
572-
if not <| isNull value then
573-
query.Add(name, value)
599+
if isNull sb then
600+
address
601+
else
602+
if not(isNull fragment) then
603+
sb.Append(fragment) |> ignore
574604

575-
builder.Query <- query.ToString()
576-
builder.Uri.PathAndQuery.TrimStart('/')
605+
sb.ToString()
577606

578607
let method = resolveHttpMethod httpMethod
579608
new HttpRequestMessage(method, Uri(requestUrl, UriKind.Relative))

tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,44 @@ module CreateHttpRequestTests =
643643
uri |> shouldContainText "page=1"
644644
uri |> shouldContainText "size=20"
645645

646+
[<Fact>]
647+
let ``createHttpRequest percent-encodes spaces in query parameter values``() =
648+
// The StringBuilder+Uri.EscapeDataString implementation encodes spaces as %20
649+
// (RFC 3986 percent-encoding) rather than + (application/x-www-form-urlencoded).
650+
use req = createHttpRequest "GET" "v1/search" [ ("q", "hello world") ]
651+
let uri = req.RequestUri.ToString()
652+
uri |> shouldContainText "q=hello%20world"
653+
uri |> shouldNotContainText "q=hello+world"
654+
uri |> shouldNotContainText "q=hello world"
655+
656+
[<Fact>]
657+
let ``createHttpRequest percent-encodes special characters in query parameter values``() =
658+
use req = createHttpRequest "GET" "v1/items" [ ("filter", "a=1&b=2") ]
659+
let uri = req.RequestUri.ToString()
660+
// & and = in values must be encoded so they are not confused with separators
661+
uri |> shouldContainText "filter=a%3D1%26b%3D2"
662+
663+
[<Fact>]
664+
let ``createHttpRequest percent-encodes special characters in parameter names``() =
665+
use req = createHttpRequest "GET" "v1/items" [ ("my param", "value") ]
666+
let uri = req.RequestUri.ToString()
667+
uri |> shouldContainText "my%20param=value"
668+
669+
[<Fact>]
670+
let ``createHttpRequest appends query params to address with existing query``() =
671+
use req =
672+
createHttpRequest "GET" "v1/items?existing=1" [ ("page", "2"); ("size", "10") ]
673+
674+
req.RequestUri.OriginalString
675+
|> shouldEqual "v1/items?existing=1&page=2&size=10"
676+
677+
[<Fact>]
678+
let ``createHttpRequest inserts query params before fragment``() =
679+
use req = createHttpRequest "GET" "v1/items?existing=1#section" [ ("page", "2") ]
680+
681+
req.RequestUri.OriginalString
682+
|> shouldEqual "v1/items?existing=1&page=2#section"
683+
646684

647685
module FillHeadersTests =
648686

tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,3 +1648,55 @@ let ``201 response in async mode resolves to Async<string> when no 200 defined``
16481648

16491649
method.ReturnType.GetGenericArguments()[0]
16501650
|> shouldEqual typeof<string>
1651+
1652+
// ── 200 response takes priority over other 2xx when both are defined ─────────
1653+
1654+
/// OpenAPI 3.0 schema where an operation defines both a 200 (string) and a 201
1655+
/// (integer) response. The 200 response must win and determine the return type.
1656+
let private twoHundredAndCreatedSchema =
1657+
"""openapi: "3.0.0"
1658+
info:
1659+
title: PriorityTest
1660+
version: "1.0.0"
1661+
paths:
1662+
/items:
1663+
post:
1664+
operationId: createItem
1665+
responses:
1666+
"200":
1667+
description: OK
1668+
content:
1669+
application/json:
1670+
schema:
1671+
type: string
1672+
"201":
1673+
description: Created
1674+
content:
1675+
application/json:
1676+
schema:
1677+
type: integer
1678+
components:
1679+
schemas: {}
1680+
"""
1681+
1682+
[<Fact>]
1683+
let ``200 response takes priority over 201 when both are defined``() =
1684+
let types = compileTaskSchema twoHundredAndCreatedSchema
1685+
let method = (findMethod types "CreateItem").Value
1686+
method.ReturnType.IsGenericType |> shouldEqual true
1687+
1688+
method.ReturnType.GetGenericTypeDefinition()
1689+
|> shouldEqual typedefof<Task<_>>
1690+
1691+
// 200 (string) must win over 201 (integer)
1692+
method.ReturnType.GetGenericArguments()[0]
1693+
|> shouldEqual typeof<string>
1694+
1695+
[<Fact>]
1696+
let ``200 response schema is used not 201 when both are present``() =
1697+
// Verify that the 201 integer schema is not used when a 200 string schema is present.
1698+
let types = compileTaskSchema twoHundredAndCreatedSchema
1699+
let method = (findMethod types "CreateItem").Value
1700+
let returnArg = method.ReturnType.GetGenericArguments()[0]
1701+
returnArg |> shouldNotEqual typeof<int32>
1702+
returnArg |> shouldEqual typeof<string>

tests/SwaggerProvider.Tests/Schema.V2SchemaCompilationTests.fs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,62 @@ let ``v2 compiled object type ToString invokeCode does not throw for concrete pr
405405
let body = invokeCode [ thisExpr ]
406406
// Expr is a value type; just verifying invokeCode did not throw is sufficient
407407
body.Type |> shouldEqual typeof<string>
408+
409+
// ── V2 operation return types ─────────────────────────────────────────────────
410+
411+
/// Finds a method on any type in the compiled result list.
412+
let private findMethod (types: ProviderImplementation.ProvidedTypes.ProvidedTypeDefinition list) (name: string) =
413+
types
414+
|> List.collect(fun t -> t.GetMethods() |> Array.toList)
415+
|> List.tryFind(fun m -> m.Name = name)
416+
417+
[<Fact>]
418+
let ``v2 listPets generates a method with Task<Pet[]> return type``() =
419+
let types = compileV2Schema minimalPetstoreV2
420+
let method = (findMethod types "ListPets").Value
421+
method.ReturnType.IsGenericType |> shouldEqual true
422+
423+
method.ReturnType.GetGenericTypeDefinition()
424+
|> shouldEqual typedefof<System.Threading.Tasks.Task<_>>
425+
426+
let returnArg = method.ReturnType.GetGenericArguments()[0]
427+
returnArg.IsArray |> shouldEqual true
428+
returnArg.GetElementType().Name |> shouldEqual "Pet"
429+
430+
[<Fact>]
431+
let ``v2 getPet generates a method with Task<Pet> return type``() =
432+
let types = compileV2Schema minimalPetstoreV2
433+
let method = (findMethod types "GetPet").Value
434+
method.ReturnType.IsGenericType |> shouldEqual true
435+
436+
method.ReturnType.GetGenericTypeDefinition()
437+
|> shouldEqual typedefof<System.Threading.Tasks.Task<_>>
438+
439+
let returnArg = method.ReturnType.GetGenericArguments()[0]
440+
returnArg.Name |> shouldEqual "Pet"
441+
442+
[<Fact>]
443+
let ``v2 getPet has an integer path parameter``() =
444+
let types = compileV2Schema minimalPetstoreV2
445+
let method = (findMethod types "GetPet").Value
446+
let parameters = method.GetParameters()
447+
// id path param (int64) + CancellationToken
448+
parameters.Length |> shouldEqual 2
449+
let idParam = parameters |> Array.find(fun p -> p.Name = "id")
450+
idParam.ParameterType |> shouldEqual typeof<int64>
451+
idParam.IsOptional |> shouldEqual false
452+
453+
[<Fact>]
454+
let ``v2 createPet generates a method with Task<IO.Stream> return type``() =
455+
// In Swagger 2.0, when the 201 response has no explicit schema and no 'produces' key,
456+
// Microsoft.OpenApi normalises the response to application/octet-stream with a null
457+
// schema, which the OperationCompiler maps to Task<IO.Stream>.
458+
let types = compileV2Schema minimalPetstoreV2
459+
let method = (findMethod types "CreatePet").Value
460+
method.ReturnType.IsGenericType |> shouldEqual true
461+
462+
method.ReturnType.GetGenericTypeDefinition()
463+
|> shouldEqual typedefof<System.Threading.Tasks.Task<_>>
464+
465+
method.ReturnType.GetGenericArguments()[0]
466+
|> shouldEqual typeof<System.IO.Stream>

0 commit comments

Comments
 (0)