From 45975be2326967913bad29d607eeb5b11120736a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 20:16:23 +0000 Subject: [PATCH 1/3] refactor: replace Regex.Replace with String.Replace for path param substitution Path parameter substitution used Regex.Replace with a literal pattern like {petId}. This required escaping '$' in the replacement value to prevent regex back-reference interpretation ($0, $& etc.). String.Replace is simpler, faster, and does not interpret any characters in the replacement string specially. The behaviour is identical: both replace every occurrence of the literal token in the path template. Removes the System.Text.RegularExpressions open and the intermediate escaped expression, reducing generated code complexity and eliminating a (small) regex engine overhead per path parameter per API call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/OperationCompiler.fs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index acde5a63..a2fbe083 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -4,7 +4,6 @@ open System open System.Collections.Generic open System.Net.Http open System.Text.Json -open System.Text.RegularExpressions open Microsoft.FSharp.Quotations open Microsoft.FSharp.Quotations.ExprShape @@ -339,9 +338,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | ParameterLocation.Path -> let value = coerceString valueExpr let pattern = $"{{%s{name}}}" - // Escape $ in the replacement to avoid regex back-reference interpretation ($0, $& etc.) - let escaped = <@ (%value).Replace("$", "$$") @> - let path' = <@ Regex.Replace(%path, pattern, %escaped) @> + let path' = <@ (%path).Replace(pattern, %value) @> (path', query, headers, cookies) | ParameterLocation.Query -> let listValues = coerceQueryString name valueExpr From 084e6d621cac6a536b2b78aa5402a5f4ede32ee9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 20:16:25 +0000 Subject: [PATCH 2/3] test: add OperationCompiler tests for multiple path params, PATCH, and auto-generated names (+7 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills three gaps in the OperationCompiler unit test suite: 1. Multiple path parameters (/users/{userId}/posts/{postId}): verify both params appear in the signature, are required, and CancellationToken comes last. 2. PATCH operation: verify a PATCH endpoint with path param + JSON body generates the expected method with correct parameter order. 3. Auto-generated operation name (no operationId): verify compilation succeeds and the generated method has the expected parameter signature (categoryId + CancellationToken) when no operationId is specified. Total: 389 → 396 tests (7 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Schema.OperationCompilationTests.fs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 5f4c2ce0..3e07098d 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -640,3 +640,175 @@ let ``default response is used as return type when no 2xx response is defined``( // The string schema from the default response should produce Task method.ReturnType.GetGenericArguments()[0] |> shouldEqual typeof + +// ── Multiple path parameters ─────────────────────────────────────────────────── + +let private multiplePathParamsSchema = + """openapi: "3.0.0" +info: + title: MultiplePathParamsTest + version: "1.0.0" +paths: + /users/{userId}/posts/{postId}: + get: + operationId: getUserPost + parameters: + - name: userId + in: path + required: true + schema: + type: integer + - name: postId + in: path + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: string +components: + schemas: {} +""" + +[] +let ``both path parameters appear as required parameters``() = + let types = compileTaskSchema multiplePathParamsSchema + let method = (findMethod types "GetUserPost").Value + let parameters = method.GetParameters() + let paramNames = parameters |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "userId" + paramNames |> shouldContain "postId" + +[] +let ``path parameters in nested path are required (not optional)``() = + let types = compileTaskSchema multiplePathParamsSchema + let method = (findMethod types "GetUserPost").Value + let parameters = method.GetParameters() + + let userIdParam = parameters |> Array.find(fun p -> p.Name = "userId") + userIdParam.IsOptional |> shouldEqual false + + let postIdParam = parameters |> Array.find(fun p -> p.Name = "postId") + postIdParam.IsOptional |> shouldEqual false + +[] +let ``multiple path params appear before CancellationToken``() = + let types = compileTaskSchema multiplePathParamsSchema + let method = (findMethod types "GetUserPost").Value + let parameters = method.GetParameters() + let lastParam = parameters |> Array.last + + lastParam.ParameterType + |> shouldEqual typeof + + parameters.Length |> shouldEqual 3 // userId, postId, CancellationToken + +// ── PATCH operation ──────────────────────────────────────────────────────────── + +let private patchSchema = + """openapi: "3.0.0" +info: + title: PatchTest + version: "1.0.0" +paths: + /items/{id}: + patch: + operationId: updateItem + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: OK +components: + schemas: {} +""" + +[] +let ``PATCH endpoint generates a method``() = + let types = compileTaskSchema patchSchema + let method = findMethod types "UpdateItem" + method.IsSome |> shouldEqual true + +[] +let ``PATCH endpoint has path param, body param, and CancellationToken``() = + let types = compileTaskSchema patchSchema + let method = (findMethod types "UpdateItem").Value + let parameters = method.GetParameters() + let paramNames = parameters |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "id" + paramNames |> shouldContain "json" + let lastParam = parameters |> Array.last + + lastParam.ParameterType + |> shouldEqual typeof + +// ── Auto-generated operation name (no operationId) ───────────────────────────── + +let private noOperationIdSchema = + """openapi: "3.0.0" +info: + title: NoOperationIdTest + version: "1.0.0" +paths: + /categories/{categoryId}/items: + get: + parameters: + - name: categoryId + in: path + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: string +components: + schemas: {} +""" + +[] +let ``operation without operationId generates a method from path and HTTP method``() = + let types = compileTaskSchema noOperationIdSchema + // Without operationId, the name is derived from method + path segments (skipping path params) + // Path: /categories/{categoryId}/items → segments: "categories", "items"; {categoryId} is skipped + // Method: GET → name candidate: Get_Items_Categories → nicePascalName → GetItemsCategories + types |> List.isEmpty |> shouldEqual false + let allMethods = types |> List.collect(fun t -> t.GetMethods() |> Array.toList) + allMethods |> List.isEmpty |> shouldEqual false + +[] +let ``operation without operationId has correct parameter count``() = + let types = compileTaskSchema noOperationIdSchema + // findMethod searches all methods on all types; just verify we find exactly one + // method that has the expected signature (categoryId + CancellationToken) + let allMethods = + types + |> List.collect(fun t -> t.GetMethods() |> Array.toList) + |> List.filter(fun m -> + let ps = m.GetParameters() + + ps.Length = 2 + && ps[0].Name = "categoryId" + && ps[1].ParameterType = typeof) + + allMethods.Length |> shouldEqual 1 From 59fde7b2821179e8e6516dfb2c8216ae8e47fe04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 21:02:33 +0000 Subject: [PATCH 3/3] test: address inline review feedback in operation compilation tests Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/34acd7e2-8a36-4b5a-8066-c5ee6fa4e67e Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../Schema.OperationCompilationTests.fs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 3e07098d..b985055e 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -702,8 +702,7 @@ let ``multiple path params appear before CancellationToken``() = let parameters = method.GetParameters() let lastParam = parameters |> Array.last - lastParam.ParameterType - |> shouldEqual typeof + lastParam.ParameterType |> shouldEqual typeof parameters.Length |> shouldEqual 3 // userId, postId, CancellationToken @@ -756,8 +755,7 @@ let ``PATCH endpoint has path param, body param, and CancellationToken``() = paramNames |> shouldContain "json" let lastParam = parameters |> Array.last - lastParam.ParameterType - |> shouldEqual typeof + lastParam.ParameterType |> shouldEqual typeof // ── Auto-generated operation name (no operationId) ───────────────────────────── @@ -789,12 +787,8 @@ components: [] let ``operation without operationId generates a method from path and HTTP method``() = let types = compileTaskSchema noOperationIdSchema - // Without operationId, the name is derived from method + path segments (skipping path params) - // Path: /categories/{categoryId}/items → segments: "categories", "items"; {categoryId} is skipped - // Method: GET → name candidate: Get_Items_Categories → nicePascalName → GetItemsCategories - types |> List.isEmpty |> shouldEqual false - let allMethods = types |> List.collect(fun t -> t.GetMethods() |> Array.toList) - allMethods |> List.isEmpty |> shouldEqual false + let method = findMethod types "GetCategoryItems" + method.IsSome |> shouldEqual true [] let ``operation without operationId has correct parameter count``() = @@ -809,6 +803,6 @@ let ``operation without operationId has correct parameter count``() = ps.Length = 2 && ps[0].Name = "categoryId" - && ps[1].ParameterType = typeof) + && ps[1].ParameterType = typeof) allMethods.Length |> shouldEqual 1