Skip to content

perf: reduce allocations & reflection in generated operation code#455

Merged
sergey-tihon merged 2 commits into
masterfrom
feature/quotations
May 28, 2026
Merged

perf: reduce allocations & reflection in generated operation code#455
sergey-tihon merged 2 commits into
masterfrom
feature/quotations

Conversation

@sergey-tihon
Copy link
Copy Markdown
Member

Summary

Reduces allocations and reflection in both the OperationCompiler (compile-time) and the code generated for every API operation (runtime). Validated end-to-end against the full Stripe spec3.json (414 paths, 1422 schemas, 587 operations).

Compile-time (OperationCompiler / Provider.OpenApiClient)

  • Hoist RuntimeHelpers.toParam / RuntimeHelpers.toQueryParams / TaskExtensions.cast / AsyncExtensions.cast MethodInfo resolution to the OperationCompiler instance — resolved once per provider instead of per generated operation.
  • Hoist string list union-case reflection in Provider.OpenApiClient.
  • Build a paramName -> IOpenApiParameter map once per operation and use it for O(1) lookups in invokeCode (was Seq.tryFind per ShapeVar).
  • Build the fixed Content-Type / Accept headers list at provider-generation time instead of emitting a quotation that re-evaluates per call.
  • Make response quotations (object / stream / string / unit) lazy so only the branch actually used for the operation's return type is built.

Generated code (runtime)

  • Emit RuntimeHelpers.createHttpRequestFromQueryLists instead of chained List.append / List.concat for query parameter assembly.
  • Emit RuntimeHelpers.fillHeadersAndCookies instead of an inline Seq.filter / Seq.map / String.concat cookie-building quotation.
  • For typed JSON responses, emit a direct generic TaskExtensions.cast<'T> / AsyncExtensions.cast<'T> call via ProvidedTypeBuilder.MakeGenericMethod, avoiding MethodInfo.Invoke on every API call.
  • Avoid the previous shared-quotation Var hazard that caused FS3033 "An item with the same key has already been added" for operations with multiple path/query parameters.

Runtime helpers (RuntimeHelpers)

  • Add createHttpRequestFromQueryLists: flattens seq<#seq<string*string>> into a ResizeArray, filtering nulls, and delegates to existing createHttpRequest.
  • Add fillHeadersAndCookies: calls fillHeaders, then builds the Cookie header imperatively with StringBuilder using the standards-compliant "; " separator and TryAddWithoutValidation.

Tests

  • Add regression tests in Schema.OperationCompilationTests that assert generated invokeCode does not reuse the same Quotations.Var instance across path/query parameters (would otherwise cause FS3033 in real providers).

Behavior changes

  • Cookie header separator changed from ";" to "; " (RFC 6265 canonical form). Servers accept both; flagged for visibility.

Validation

  • dotnet fantomas --check src/ tests/
  • Unit tests: 479 total, 0 failed, 1 skipped ✅
  • Provider integration tests (Swashbuckle server on localhost:5000): 127 total, 0 failed ✅
  • Stripe spec3.json (414 paths, 1422 schemas, 587 operations) compiles successfully.
  • Generated-code inspection on Stripe:
    • reflective casts: 0
    • generated List.append calls: 0
    • generated List.concat calls: 0
    • duplicate quotation Var bindings: 0

Compile-time (OperationCompiler):
- Hoist toParam / toQueryParams / TaskExtensions.cast / AsyncExtensions.cast
  MethodInfo resolution to the OperationCompiler instance (resolved once
  per provider instead of per generated operation).
- Hoist string-list union-case reflection in Provider.OpenApiClient.
- Build a paramName -> IOpenApiParameter map once per operation and use
  it for O(1) lookups in invokeCode (was Seq.tryFind per ShapeVar).
- Build the fixed Content-Type/Accept headers list at provider-generation
  time instead of emitting a quotation that re-evaluates per call.
- Make response quotations (object / stream / string / unit) lazy so only
  the branch actually used for the operation's return type is built.

Generated code (runtime):
- Emit RuntimeHelpers.createHttpRequestFromQueryLists instead of chained
  List.append / List.concat for query parameter assembly.
- Emit RuntimeHelpers.fillHeadersAndCookies instead of an inline
  Seq.filter / Seq.map / String.concat cookie-building quotation.
- For typed JSON responses, emit a direct generic TaskExtensions.cast<'T> /
  AsyncExtensions.cast<'T> call via ProvidedTypeBuilder.MakeGenericMethod,
  avoiding MethodInfo.Invoke on every API call.
- Avoid the previous shared-quotation Var hazard that caused FS3033
  'An item with the same key has already been added' for operations with
  multiple path/query parameters.

Runtime helpers (RuntimeHelpers):
- Add createHttpRequestFromQueryLists: flattens seq<#seq<string*string>>
  into a ResizeArray, filtering nulls, and delegates to createHttpRequest.
- Add fillHeadersAndCookies: calls fillHeaders, then builds the Cookie
  header imperatively with StringBuilder using the standards-compliant
  '; ' separator and TryAddWithoutValidation.

Tests:
- Add regression tests in Schema.OperationCompilationTests that assert
  generated invokeCode does not reuse the same Quotations.Var instance
  across path/query parameters.

Validated:
- dotnet fantomas --check src/ tests/
- Unit tests: 479 total, 0 failed, 1 skipped
- Provider integration tests (Swashbuckle server): 127 total, 0 failed
- Stripe spec3.json (414 paths, 1422 schemas, 587 operations) compiles
  successfully; generated code contains 0 reflective casts, 0 List.append
  and 0 List.concat calls.
Copilot AI review requested due to automatic review settings May 28, 2026 06:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes OpenAPI operation generation by reducing repeated reflection and simplifying generated runtime request/response code.

Changes:

  • Hoists method/union-case reflection and parameter lookup setup into provider/operation compilation paths.
  • Adds runtime helpers for query-list flattening and combined header/cookie filling.
  • Adds regression tests for duplicate quotation Var reuse in generated invoke code.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/SwaggerProvider.DesignTime/OperationCompiler.fs Optimizes generated operation code and response casting.
src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs Hoists string-list union-case lookup.
src/SwaggerProvider.Runtime/RuntimeHelpers.fs Adds query-list and header/cookie runtime helpers.
tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs Adds quotation Var reuse regression tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Comment thread src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Address Copilot review feedback on PR #455 by adding focused
RuntimeHelpersTests for the two new helpers:

createHttpRequestFromQueryLists:
- flattens multiple query lists into the request URI
- strips leading slash when all lists are empty
- strips leading slash when all inner lists are empty
- skips null-valued pairs across multiple lists
- produces no query string when all values are null
- preserves the HTTP method

fillHeadersAndCookies:
- emits Cookie header with canonical '; ' separator
- single cookie has no separator
- skips null cookie values
- omits Cookie header when all values are null
- omits Cookie header when cookie list is empty
- still adds normal headers via fillHeaders
- still skips null-value headers
@sergey-tihon sergey-tihon merged commit 00d85c0 into master May 28, 2026
2 checks passed
@sergey-tihon sergey-tihon deleted the feature/quotations branch May 28, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants