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).

Hints from issue comments:
@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: [] } }
+    });
+  });
+});