Repository: hey-api/openapi-ts (TypeScript)

Issue:
@tanstack/react-query with pagination keywords causes type error
### Description

While combining custom pagination keywords and tanstack query plugin: 
```
pagination: {
  keywords: ['pageSize', 'pageToken'],
  plugins: ['@tanstack/react-query']
},
```

The generated react-query code has type errors due to wrong casing:
```
query: {
    pagesize: pageParam, // Should be pageSize
},
```

### Reproducible example or configuration

https://stackblitz.com/edit/hey-api-client-fetch-example-pbmmlfen?file=src%2Fclient%2F%40tanstack%2Freact-query.gen.ts

### OpenAPI specification (optional)

```
openapi: 3.1.0
info:
  title: Test API - Minimal Pagination Example
  description: A minimal API example demonstrating pagination with pageSize and pageToken
  version: 1.0.0
servers:
  - url: https://api.example.com
    description: Example server

tags:
  - name: Items
    description: Test items operations

paths:
  /test/v1/items:
    get:
      summary: List items
      description: |
        Retrieve a paginated list of test items.

        This endpoint demonstrates pagination using:
        - `pageSize`: Controls how many items are returned per page (1-100, default 20)
        - `pageToken`: Used to fetch subsequent pages of results

        The response includes a `nextPageToken` when more results are available.
      operationId: listItems
      tags:
        - Items
      parameters:
        - name: pageSize
          in: query
          description: Maximum number of items to return per page
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          example: 10
        - name: pageToken
          in: query
          description: |
            Token for the specific page to return. 
            Use the `nextPageToken` from a previous response to get the next page.
            Omit this parameter to get the first page.
          required: false
          schema:
            type: string
          example: 'eyJhZnRlciI6Iml0ZW0tMTIzIiwibGltaXQiOjEwfQ'
      responses:
        '200':
          description: Paginated list of items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Item'
                    description: Array of item objects for the current page
                  nextPageToken:
                    type: string
                    description: |
                      Token to retrieve the next page of results.
                      This field is omitted when there are no more pages.
                    nullable: true
                  totalCount:
                    type: integer
                    description: Total number of items available (optional)
                    example: 150
                required:
                  - items
              examples:
                first_page:
                  summary: First page with next page available
                  value:
                    items:
                      - id: 'item-001'
                        name: 'Test Item Alpha'
                        description: 'First test item for demonstration'
                        version: '1.0.0'
                        createdAt: '2024-03-20T10:00:00Z'
                        updatedAt: '2024-03-20T10:00:00Z'
                      - id: 'item-002'
                        name: 'Test Item Beta'
                        description: 'Second test item for demonstration'
                        version: '1.0.0'
                        createdAt: '2024-03-21T10:00:00Z'
                        updatedAt: '2024-03-21T10:00:00Z'
                    nextPageToken: 'eyJhZnRlciI6Iml0ZW0tMDAyIiwibGltaXQiOjEwfQ'
                    totalCount: 150
                last_page:
                  summary: Last page with no more results
                  value:
                    items:
                      - id: 'item-149'
                        name: 'Test Item Omega'
                        description: 'Last test item in the collection'
                        version: '1.0.0'
                        createdAt: '2024-06-20T10:00:00Z'
                        updatedAt: '2024-06-20T10:00:00Z'
                    totalCount: 150
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalid_page_size:
              summary: Invalid pageSize parameter
              value:
                error: 'pageSize must be between 1 and 100'
            invalid_page_token:
              summary: Invalid pageToken parameter
              value:
                error: 'Invalid pageToken format'

    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: 'Internal server error. Please try again later.'

  schemas:
    Error:
      type: object
      description: Standard error response
      properties:
        error:
          type: string
          description: Error message describing what went wrong
      required:
        - error

    Item:
      type: object
      description: A test item
      properties:
        id:
          type: string
          description: Unique identifier for the item
          example: 'item-001'
        name:
          type: string
          description: Name of the test item
          example: 'Test Item Alpha'
        description:
          type: string
          description: Detailed description of the item
          example: 'First test item for demonstration'
        version:
          type: string
          description: Version of the item
          example: '1.0.0'
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the item was created
          example: '2024-03-20T10:00:00Z'
        updatedAt:
          type: string
          format: date-time
          description: Timestamp when the item was last updated
          example: '2024-03-20T10:00:00Z'
      required:
        - id
        - name
        - description
        - version
        - createdAt
        - updatedAt

```

### System information (optional)

_No response_


Discussion:
<a href='https://stackblitz.com/~/github.com/hey-api/openapi-ts/issues/2232?repros=hey-api-client-fetch-example-pbmmlfen'><img src='https://developer.stackblitz.com/img/fix_this_issue_small.svg' alt='Fix this issue in StackBlitz Codeflow' align='left' width='117' height='20'></a> _Start a new pull request in [StackBlitz Codeflow](https://stackblitz.com/~/github.com/hey-api/openapi-ts/issues/2232?repros=hey-api-client-fetch-example-pbmmlfen)._

- [**hey-api-client-fetch-example-pbmmlfen** (Open reproduction)](https://stackblitz.com/edit/hey-api-client-fetch-example-pbmmlfen?issueRepo=hey-api/openapi-ts&issueNumber=2232)

Same issue here
As near as I can tell, the offending lines (at least for OpenAPI 3.1) are here:
https://github.com/hey-api/openapi-ts/blob/078d957ddc4f8fea6ffeab503693ba1886834072/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts#L81-L86

If I remove the `toLocaleLowerCase()` from line 82, the generated output seems to be correct. I don't see any configuration around preserving case for the areas of code that use that method - `parametersArrayToObject()`
@bozzaj hmm, I'm not sure what the solution is, but that lowercase cast exists for header parameters. For example, we want to avoid having both `Authorization` and `authorization` because this leads to unexpected results. Simply removing the lowercase cast would break this functionality. Maybe we can keep it only for headers and remove for every other location?
@mrlubos On the surface, that might make sense for headers, as (per HTTP spec) headers are case insensitive. However, query parameter case is dependent on implementation. I know that some implementations (Like Microsoft ASP) will treat query strings as case-insensitive, but that's not always the case.

The odd thing is that the type definitions for query parameters are correct. Using the example in the original issue, `pageSize" is correctly cased in the type for `ListItems` (operationId of `listItems), query, but when it gets to the generation for `infiniteQueryOptions`, it's been lowercased. 

It's taking me a little to understand and trace the code, but it seems like the types for `query` are pulled directly from the OpenAPI document, but the pagination parameters are pulled from the generated parameter arrays, which have all be lowercased.

Looking at the method `parameterWithPagination`, out of the four locations (cookie, header, path, query), only the header parameters would possibly have case normalized since the HTTP spec states that headers should be case insensitive. All other parameters could be case-sensitive.

I'm not convinced that code generation should care about (HTTP) implementation though. It is perfectly acceptable for an OpenAPI document to define a header parameter named `Authorization`, or `PageSize`, or whatever you want to define, even if HTTP spec states that (at runtime) headers are considered case-insensitive. If someone creates an OpenAPI JSON document with a header parameter of `PageSize` and generates a client, the generated code uses `PageSize` in the header type definitions, but `infinityQueryOptions` uses `pagesize`, which still breaks the types. Granted, many (most?) HTTP client implementations will lowercase headers before they're returned, which might break any client that automatically looks for response headers and doesn't consider the case requirements, I would state that should be the responsibility of the person implementing the actual client (in this case `@tanstack/react-query` or the person creating the OpenAPI JSON in the first place) and not here. 

@bozzaj I don't disagree with any of what you said. Let me show you why normalizing casing is needed, for example in https://github.com/hey-api/openapi-ts/blob/5e8195ced8e3ec32cca102b56495233c8fe2f52d/packages/openapi-ts/src/plugins/@hey-api/sdk/operation.ts#L653

If we fail to detect presence of certain keys such as `Content-Type` or `Authorization`, we could run into the following problems:
- earlier mentioned undefined behavior for duplicate headers, e.g. `Authorization` and `authorization`
- TypeScript errors due to overriding keys with spread, e.g. object with `Foo` and spread of another object containing `Foo`

Specifically the second issue is something that was reported before, though I can't find it right now. As you noticed however, we preserve the original names. I'm hopeful this is just an issue with using the normalized name and will be easy to fix 🤞 
@mrlubos Yep, that makes sense, but if you look at that code, it looks for the header `content-type` (normalized) but then, 6 lines later, you see this:
https://github.com/hey-api/openapi-ts/blob/5e8195ced8e3ec32cca102b56495233c8fe2f52d/packages/openapi-ts/src/plugins/%40hey-api/sdk/operation.ts#L659

So, if someone doesn't specify the `content-type`, it gets placed in as `Content-Type`.

Your last sentence seems like the right resolution. Normalization for the purposes of comparisons, but for the output, utilizing the actual name. Unfortunately, I don't understand the code enough yet (only started looking at it last night) to see an easy path to that.
Yeah, that's because I think `Content-Type` is more common than the lowercased version, but I digress. Let me quickly have a look at this bug
This will be fixed in v0.80.8 🤝 



Patch:
diff --git a/.changeset/perfect-berries-beam.md b/.changeset/perfect-berries-beam.md
new file mode 100644
index 0000000000..bb9576281f
--- /dev/null
+++ b/.changeset/perfect-berries-beam.md
@@ -0,0 +1,5 @@
+---
+'@hey-api/openapi-ts': patch
+---
+
+fix(tanstack-query): set correct name for pagination parameters in infinite query options
diff --git a/packages/openapi-ts/src/ir/pagination.ts b/packages/openapi-ts/src/ir/pagination.ts
index 844f0c1e92..cb1a7a466e 100644
--- a/packages/openapi-ts/src/ir/pagination.ts
+++ b/packages/openapi-ts/src/ir/pagination.ts
@@ -9,7 +9,7 @@ export function getPaginationKeywordsRegExp(
 }
 
 export interface Pagination {
-  in: string;
+  in: 'body' | 'cookie' | 'header' | 'path' | 'query';
   name: string;
   schema: IR.SchemaObject;
 }
diff --git a/packages/openapi-ts/src/ir/parameter.ts b/packages/openapi-ts/src/ir/parameter.ts
index 56903e25fe..4fe60ccce3 100644
--- a/packages/openapi-ts/src/ir/parameter.ts
+++ b/packages/openapi-ts/src/ir/parameter.ts
@@ -80,8 +80,8 @@ export const parameterWithPagination = ({
         in: parameter.location,
         name:
           parameter.pagination === true
-            ? name
-            : `${name}.${parameter.pagination}`,
+            ? parameter.name
+            : `${parameter.name}.${parameter.pagination}`,
         schema: getPaginationSchema({ context, parameter })!,
       };
     }
@@ -94,8 +94,8 @@ export const parameterWithPagination = ({
         in: parameter.location,
         name:
           parameter.pagination === true
-            ? name
-            : `${name}.${parameter.pagination}`,
+            ? parameter.name
+            : `${parameter.name}.${parameter.pagination}`,
         schema: getPaginationSchema({ context, parameter })!,
       };
     }
@@ -108,8 +108,8 @@ export const parameterWithPagination = ({
         in: parameter.location,
         name:
           parameter.pagination === true
-            ? name
-            : `${name}.${parameter.pagination}`,
+            ? parameter.name
+            : `${parameter.name}.${parameter.pagination}`,
         schema: getPaginationSchema({ context, parameter })!,
       };
     }
@@ -122,8 +122,8 @@ export const parameterWithPagination = ({
         in: parameter.location,
         name:
           parameter.pagination === true
-            ? name
-            : `${name}.${parameter.pagination}`,
+            ? parameter.name
+            : `${parameter.name}.${parameter.pagination}`,
         schema: getPaginationSchema({ context, parameter })!,
       };
     }


Test patch:
diff --git a/packages/openapi-ts/src/ir/__tests__/pagination.test.ts b/packages/openapi-ts/src/ir/__tests__/pagination.test.ts
index 5e79bc10f7..904666d550 100644
--- a/packages/openapi-ts/src/ir/__tests__/pagination.test.ts
+++ b/packages/openapi-ts/src/ir/__tests__/pagination.test.ts
@@ -7,7 +7,7 @@ import { getPaginationKeywordsRegExp } from '../pagination';
 import type { IR } from '../types';
 
 describe('paginationKeywordsRegExp', () => {
-  const defaultScenarios: Array<{
+  const defaultScenarios: ReadonlyArray<{
     result: boolean;
     value: string;
   }> = [
@@ -55,7 +55,7 @@ describe('paginationKeywordsRegExp', () => {
     },
   );
 
-  const customScenarios: Array<{
+  const customScenarios: ReadonlyArray<{
     result: boolean;
     value: string;
   }> = [
@@ -98,7 +98,7 @@ describe('operationPagination', () => {
     path: '/test' as const,
   };
 
-  const queryScenarios: Array<{
+  const queryScenarios: ReadonlyArray<{
     hasPagination: boolean;
     operation: IR.OperationObject;
   }> = [
@@ -128,19 +128,38 @@ describe('operationPagination', () => {
         },
       },
     },
+    {
+      hasPagination: true,
+      operation: {
+        ...baseOperationMeta,
+        id: 'op3',
+        method: 'get',
+        parameters: {
+          query: {
+            pagesize: queryParam('pageSize', 'string', true),
+          },
+        },
+      },
+    },
   ];
 
   it.each(queryScenarios)(
     'query params for $operation.id → $hasPagination',
-    ({
-      hasPagination,
-      operation,
-    }: {
-      hasPagination: boolean;
-      operation: IR.OperationObject;
-    }) => {
-      const result = operationPagination({ context: emptyContext, operation });
-      expect(Boolean(result)).toEqual(hasPagination);
+    ({ hasPagination, operation }: (typeof queryScenarios)[number]) => {
+      const pagination = operationPagination({
+        context: emptyContext,
+        operation,
+      });
+      expect(Boolean(pagination)).toEqual(hasPagination);
+      if (pagination && pagination.in !== 'body') {
+        const parameter =
+          operation.parameters?.[pagination.in]?.[
+            pagination.name.toLocaleLowerCase()
+          ];
+        if (parameter) {
+          expect(pagination.name).toBe(parameter.name);
+        }
+      }
     },
   );