Repository: cdimascio/express-openapi-validator

Issue:
Validation wrongly fails when using query parameters with literal bracketed names
**Describe the bug**
Query parameters defined with bracketed names like `"filter[name]"` systematically fail validation.

**To Reproduce**
```yaml
openapi: 3.0.4
info:
  title: Test
  version: 0.0.0
paths:
  /test:
    get:
      parameters:
        - name: filter[name]
          in: query
          required: false
          schema:
            type: string
          explode: false
      responses:
        200:
          description: whatever
          content:
            text/plain:
              schema:
                type: string
```

**Actual behavior** 

Using the above schema, a request like `GET /test?filter[name]=foo`, while properly following the api specification, fails with the following error : "Bad Request: Unknown query parameter 'filter'". 

express-openapi-validator recently started to parse querystrings with `qs`. `qs` interprets filter[name]=foo as a nested object: `{ filter: { name: "foo" } }`.

As a result, the validator sees a filter parameter (not filter[name]) and rejects the request.

**Expected behavior**

The query parameter name is literally `"filter[name]"` (not `"filter"` with a nested `"name"` field). According to the OpenAPI spec, this should be treated as a flat string key and accepted as valid input.

**Examples and context**

I’m deliberately avoiding object schemas for query parameters in my open api spec as they introduce ambiguity.

Indeed, there is no standard or consensus in how object should be represented in a querystring. Format such as `?filter[name]=foo` is common, but so is `?filter=name,foo` (ugh). I've also seen things like `?filter={"name":"foo"}` (i.e. json in query). You may also note that JS standard `URLSearchParams` does not parse any of the above format as object, since it only supports (non-unique) key value pairs as string.

Using literal bracketed names fixes this ambiguity.


**Workaround**

I haven't found any at the moment but pinning express-openapi-validator's version to 5.4.9.

**Suggested resolution**

Rely on express' query parsing (that is user configurable) instead of introducing an other, different one (that may conflict with the one defined by the library’s consumer).

Discussion:
@avocadojesus: A similar issue happens when using brackets for array query params. When the openapi document uses literal brackets, i.e.

```
 {
          "in": "query",
          "name": "myArray[]",
          "description": "myArray[]",
          "allowReserved": true,
          "schema": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "required": false
 }
```

I get the following error when sending `myArray[]=a&myArray[]=b&myArray[]=c`:

```
{"name":"Bad Request","status":400,"path":"/query/myArray","errors":[{"path":"/query/myArray","message":"Unknown query parameter 'myArray'"}]}
```

When switching to non-explicit brackets in the openapi document, it now passes openapi validation, but fails to convert the provided query param into an array, instead preserving it as a string with comma-separated values.

```
 {
          "in": "query",
          "name": "myArray",
          "description": "myArray",
          "allowReserved": true,
          "schema": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "required": false
}
```

passes validation, but now this is the value for the param:

```
[ 'a,b,c' ]
```

It seems to correctly place the value into an array, but each of the values is in a comma-separated string. This only happens when switching from `5.4.3` to `5.5.3`, which we did to close out a critical dependabot alert for multer. This is breaks existing implementation for us, so we are unable to update to patch this issue until we have a path forward for this problem.

Also, would like to say thanks for maintaining this amazing library, it has hugely benefited our teams 💯 @cdimascio: fix in v.5.5.7@RayzRazko: This is still not fixed. I'm on `v.5.6.0`, 

oas:
```yml
openapi: 3.0.4
info:
  title: Test
  version: 0.0.0
paths:
  /test:
    get:
      parameters:
        - name: filter[name]
          in: query
          required: false
          schema:
            type: string
          explode: false
      responses:
        200:
          description: whatever
          content:
            text/plain:
              schema:
                type: string
```

Startup:
```javascript
import express from 'express'
import OpenApiValidator from 'express-openapi-validator';

const app = express()

app.use(
    OpenApiValidator.middleware({
        apiSpec: './oas.yaml',
    })
)

app.get('/test', (req, res) => {
    res.send('Hello World')
});

app.listen(8080)
```

Package.json:
```json
{
  "name": "oas-issue",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "express": "^5.1.0",
    "express-openapi-validator": "^5.6.0"
  }
}
```


It looks like the #1084 relies on:
https://github.com/cdimascio/express-openapi-validator/blob/6493afed4bd1a9cdf12620b9f8d69f1c9408fd5c/src/middlewares/parsers/req.parameter.mutator.ts#L559
```typescript
const openApiParams = (this.query._openapi?.schema?.parameters ||
      []) as ParameterObject[];
```

But the query that is passed does not contain `_openapi` property:
https://github.com/cdimascio/express-openapi-validator/blob/6493afed4bd1a9cdf12620b9f8d69f1c9408fd5c/src/middlewares/parsers/req.parameter.mutator.ts#L71

Solution patch:
diff --git a/src/middlewares/parsers/req.parameter.mutator.ts b/src/middlewares/parsers/req.parameter.mutator.ts
index 62c1fb4..f095615 100644
--- a/src/middlewares/parsers/req.parameter.mutator.ts
+++ b/src/middlewares/parsers/req.parameter.mutator.ts
@@ -100,7 +100,6 @@ export class RequestParameterMutator {
         this.handleContent(req, name, parameter);
       } else if (parameter.in === 'query' && this.isObjectOrXOf(schema)) {
         // handle bracket notation and mutates query param
-        
 
         if (style === 'form' && explode) {
           this.parseJsonAndMutateRequest(req, parameter.in, name);
@@ -119,7 +118,7 @@ export class RequestParameterMutator {
           this.parseJsonAndMutateRequest(req, parameter.in, name);
         } else {
           this.parseJsonAndMutateRequest(req, parameter.in, name);
-      }
+        }
       } else if (type === 'array' && !explode) {
         const delimiter = ARRAY_DELIMITER[parameter.style];
         this.validateArrayDelimiter(delimiter, parameter);
@@ -428,78 +427,153 @@ export class RequestParameterMutator {
     }, new Map<string, string[]>());
   }
 
-  private csvToKeyValuePairs(csvString: string): Record<string, string> | undefined {
+  private csvToKeyValuePairs(
+    csvString: string,
+  ): Record<string, string> | undefined {
     const hasBrace = csvString.split('{').length > 1;
     const items = csvString.split(',');
-    
+
     if (hasBrace) {
       // if it has a brace, we assume its JSON and skip creating k v pairs
       // TODO improve json check, but ensure its cheap
       return;
     }
-  
+
     if (items.length % 2 !== 0) {
-      // if the number of elements is not event, 
+      // if the number of elements is not event,
       // then we do not have k v pairs, so return undefined
       return;
     }
 
     const result = {};
-    
+
     for (let i = 0; i < items.length - 1; i += 2) {
       result[items[i]] = items[i + 1];
     }
-    
+
     return result;
   }
 
   /**
    * Handles query parameters with bracket notation.
-   * - If the parameter in the OpenAPI spec has literal brackets in its name (e.g., 'filter[name]'),
-   *   it will be treated as a literal parameter name.
-   * - Otherwise, it will be parsed as a nested object using qs.
    * @param query The query parameters object to process
    * @returns The processed query parameters object
    */
   private handleBracketNotationQueryFields(query: { [key: string]: any }): {
     [key: string]: any;
   } {
-    // Get the OpenAPI parameters for the current request
-    const openApiParams = (query._openapi?.schema?.parameters || []) as ParameterObject[];
-    
-    // Create a Set of parameter names that have literal brackets in the spec
-    const literalBracketParams = new Set<string>(
-      openApiParams
-        .filter(p => p.in === 'query' && p.name.includes('[') && p.name.endsWith(']'))
-        .map(p => p.name)
-    );
+    const handler = new BracketNotationHandler(query);
+    return handler.process();
+  }
+}
+
+/**
+ * Handles parsing of query parameters with bracket notation.
+ * - If a parameter in the OpenAPI spec has literal brackets in its name (e.g., 'filter[name]'),
+ *   it will be treated as a literal parameter name.
+ * - Otherwise, it will be parsed as a nested object using qs.
+ */
+class BracketNotationHandler {
+  // Cache for literal bracket parameters per endpoint
+  private static readonly literalBracketParamsCache = new Map<
+    string,
+    Set<string>
+  >();
+
+  constructor(private readonly query: { [key: string]: any }) {}
+
+  /**
+   * Process the query parameters to handle bracket notation
+   */
+  public process(): { [key: string]: any } {
+    const literalBracketParams = this.getLiteralBracketParams();
 
-    // Create a new object to avoid mutating the original during iteration
-    const result: { [key: string]: any } = { ...query };
-    
+    const query = this.query;
     Object.keys(query).forEach((key) => {
       // Only process keys that contain brackets
-      if (key.includes('[') && key.endsWith(']')) {
-        if (literalBracketParams.has(key)) {
-          // If the parameter is defined with literal brackets in the spec, preserve it as-is
-          result[key] = query[key];
-        } else {
-          // Otherwise, use qs.parse to handle it as a nested object
+      if (key.includes('[')) {
+        // Only process keys that do not contain literal bracket notation
+        if (!literalBracketParams.has(key)) {
+          // Use qs.parse to handle it as a nested object
           const normalizedKey = key.split('[')[0];
           const parsed = parse(`${key}=${query[key]}`);
-          
+
           // Use the parsed value for the normalized key
           if (parsed[normalizedKey] !== undefined) {
-            result[normalizedKey] = parsed[normalizedKey];
+            // If we already have a value for this key, merge the objects
+            if (
+              query[normalizedKey] &&
+              typeof query[normalizedKey] === 'object' &&
+              typeof parsed[normalizedKey] === 'object' &&
+              !Array.isArray(parsed[normalizedKey])
+            ) {
+              query[normalizedKey] = {
+                ...query[normalizedKey],
+                ...parsed[normalizedKey],
+              };
+            } else {
+              query[normalizedKey] = parsed[normalizedKey];
+            }
           }
-          
-          // Remove the original bracketed key
-          delete result[key];
+
+          // Remove the original bracketed key from the query
+          delete query[key];
         }
       }
     });
-    
-    return result;
+
+    return query;
+  }
+
+  /**
+   * Gets a cache key for the current request's OpenAPI schema
+   * Combines path, method, and operation ID to create a key
+   */
+  private getCacheKey(): string | null {
+    const schema = this.query._openapi?.schema;
+    if (!schema) return null;
+
+    // Use all available identifiers to ensure uniqueness
+    const path = schema.path ?? '';
+    const method = schema.method ?? '';
+    const operationId = schema.operationId ?? '';
+
+    // Combine all parts with a consistent separator
+    return `${path}|${method}|${operationId}`;
+  }
+
+  /**
+   * Gets the set of parameter names that should be treated as literal bracket notation
+   */
+  private getLiteralBracketParams(): Set<string> {
+    const cacheKey = this.getCacheKey();
+
+    if (
+      cacheKey &&
+      BracketNotationHandler.literalBracketParamsCache.has(cacheKey)
+    ) {
+      return BracketNotationHandler.literalBracketParamsCache.get(cacheKey)!;
+    }
+
+    // Get the OpenAPI parameters for the current request
+    const openApiParams = (this.query._openapi?.schema?.parameters ||
+      []) as ParameterObject[];
+
+    // Create a Set of parameter names that have literal brackets in the spec
+    const literalBracketParams = new Set<string>(
+      openApiParams
+        .filter((p) => p.in === 'query' && p.name.includes('['))
+        .map((p) => p.name),
+    );
+
+    // Cache the result for future requests to this endpoint
+    if (cacheKey) {
+      BracketNotationHandler.literalBracketParamsCache.set(
+        cacheKey,
+        literalBracketParams,
+      );
+    }
+
+    return literalBracketParams;
   }
-  
 }



Test patch:
diff --git a/test/query.bracket.notation.spec.ts b/test/query.bracket.notation.spec.ts
new file mode 100644
index 0000000..93b15c1
--- /dev/null
+++ b/test/query.bracket.notation.spec.ts
@@ -0,0 +1,130 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { RequestParameterMutator } from '../src/middlewares/parsers/req.parameter.mutator';
+import Ajv from 'ajv';
+import { OpenAPIV3 } from '../src/framework/types';
+
+type DocumentV3 = OpenAPIV3.DocumentV3;
+
+describe('RequestParameterMutator - handleBracketNotationQueryFields', () => {
+  const ajv = new Ajv();
+  const mockApiDoc: DocumentV3 = {
+    openapi: '3.0.0',
+    info: { title: 'Test', version: '1.0.0' },
+    paths: {}
+  };
+
+  const createMockRequest = (query: any) => ({
+    query,
+    _openapi: {
+      schema: {
+        parameters: [
+          // This simulates the parameters defined in the OpenAPI spec
+          { name: 'filter[name]', in: 'query', schema: { type: 'string' } },
+          { name: 'user', in: 'query', schema: { type: 'object' } }
+        ]
+      }
+    }
+  });
+
+  const testValidationSchema = {
+    body: {},
+    query: {
+      type: 'object',
+      properties: {
+        'filter[name]': { type: 'string' },
+        'user': { type: 'object' },
+        'user[name]': { type: 'string' },
+        'user[age]': { type: 'string' },
+        'simple': { type: 'string' },
+        'another': { type: 'string' }
+      },
+      additionalProperties: true
+    },
+    headers: {},
+    params: {},
+    cookies: {}
+  };
+
+  it('should preserve literal bracket notation when defined in the spec', () => {
+    // Arrange
+    const req = createMockRequest({
+      'filter[name]': 'test',
+      _openapi: { schema: { parameters: [{ name: 'filter[name]', in: 'query' }] } }
+    });
+    const mutator = new RequestParameterMutator(ajv, mockApiDoc, '/test', testValidationSchema);
+
+    // Act
+    const result = mutator['handleBracketNotationQueryFields'](req.query);
+
+    // Assert
+    expect(result).to.have.property('filter[name]', 'test');
+    expect(result).to.not.have.property('filter');
+  });
+
+  it('should parse bracket notation as nested object when not defined in spec', () => {
+    // Arrange
+    const req = createMockRequest({
+      'user[name]': 'John',
+      'user[age]': '30',
+      _openapi: { schema: { parameters: [{ name: 'user', in: 'query', schema: { type: 'object' } }] } }
+    });
+    const mutator = new RequestParameterMutator(ajv, mockApiDoc, '/test', testValidationSchema);
+
+    // Act
+    const result = mutator['handleBracketNotationQueryFields'](req.query);
+
+    // Assert
+    expect(result).to.have.property('user');
+    expect(result.user).to.deep.equal({ name: 'John', age: '30' });
+    expect(result).to.not.have.property('user[name]');
+    expect(result).to.not.have.property('user[age]');
+  });
+
+  it('should handle mixed literal and nested bracket notation', () => {
+    // Arrange
+    const req = createMockRequest({
+      'filter[name]': 'test',
+      'user[age]': '30',
+      _openapi: { 
+        schema: { 
+          parameters: [
+            { name: 'filter[name]', in: 'query', schema: { type: 'string' } },
+            { name: 'user', in: 'query', schema: { type: 'object' } }
+          ] 
+        } 
+      }
+    });
+    const mutator = new RequestParameterMutator(ajv, mockApiDoc, '/test', testValidationSchema);
+
+    // Act
+    const result = mutator['handleBracketNotationQueryFields'](req.query);
+
+    // Assert
+    expect(result).to.have.property('filter[name]', 'test');
+    expect(result).to.have.property('user');
+    expect(result.user).to.deep.equal({ age: '30' });
+    expect(result).to.not.have.property('filter');
+    expect(result).to.not.have.property('user[age]');
+  });
+
+  it('should not modify parameters without brackets', () => {
+    // Arrange
+    const req = createMockRequest({
+      simple: 'value',
+      another: 'test',
+      _openapi: { schema: { parameters: [] } }
+    });
+    const mutator = new RequestParameterMutator(ajv, mockApiDoc, '/test', testValidationSchema);
+    testValidationSchema
+    // Act
+    const result = mutator['handleBracketNotationQueryFields'](req.query);
+
+    // Assert
+    expect(result).to.deep.equal({
+      simple: 'value',
+      another: 'test',
+      _openapi: { schema: { parameters: [] } }
+    });
+  });
+});