Repository: opensearch-project/OpenSearch-Dashboards (TypeScript)

Issue:
Dev Tools should support PATCH method
### Reproduction:
- Find some documentation about a scenario in OpenSearch APIs that use a PATCH command, [like](https://opensearch.org/docs/latest/security-plugin/access-control/api/#patch-role)
- Get excited to try it out in your own cluster
- Open Dashboards, login as an admin user
- Navigate to Dev Tools
- Paste the following into the console window
```
PATCH _plugins/_security/api/roles/my_role
[
  {
    "op": "replace", "path": "/index_permissions/0/fls", "value": ["myfield1", "myfield2"]
  },
  {
    "op": "remove", "path": "/index_permissions/0/dls"
  }
]
```
- Select that text and hit the 'send request' button

### Expected:
The request should succeed

### Actual:
An error message appears in the response window:
```
{"statusCode":400,"error":"Bad Request","message":"[request query.method]: Method must be one of, case insensitive ['HEAD', 'GET', 'POST', 'PUT', 'DELETE']. Received 'PATCH'."}
```

Be sad you can't use this great tool to vet the scenario called out in the documentation

[BUG] PATCH API calls are not supported in DevTools console
**Describe the bug**

OpenSearch supports some `PATCH` API calls like (from [documentation](https://docs.opensearch.org/docs/latest/security/access-control/api/#request-36)):
```
PATCH _plugins/_security/api/securityconfig
[
  {
    "op": "replace", "path": "/config/dynamic/authc/basic_internal_auth_domain/transport_enabled", "value": "true"
  }
]
```

calling with with `curl` works as expected, but there is no way to trigger them from DevTools console:
```
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "[request query.method]: Method must be one of, case insensitive ['HEAD', 'GET', 'POST', 'PUT', 'DELETE']. Received 'PATCH'."
}
```

**To Reproduce**
Steps to reproduce the behavior:
1. Go to DevTools console
2. Try to run any patch API, like
```
PATCH _plugins/_security/api/securityconfig
[
  {
    "op": "replace", "path": "/config/dynamic/authc/basic_internal_auth_domain/transport_enabled", "value": "true"
  }
]
```
4. Immediately you can see a parsing error `Unexpected 'A'` as the interface does not allow `PATCH`.

**Expected behavior**
One should be able to perform `PATCH` API calls from DevTools console.

**OpenSearch Version**
Tested on `v3.1` and `v2.18`

**Dashboards Version**
Tested on `v3.1` and `v2.18`

**Plugins**

Default plugins

**Screenshots**

<img width="1491" height="345" alt="Image" src="https://github.com/user-attachments/assets/37038e2e-6b51-493b-9720-d7a6fb4805e3" />

**Host/Environment (please complete the following information):**
 - OS: AlmaLinux 9.6
 - Browser and version Safari Version 18.5 (20621.2.5.11.8)




Discussion: FYI @cwillum 
It seems that the request method simply isn't in the list here:

https://github.com/opensearch-project/OpenSearch-Dashboards/blob/06abe83e0180f391a84c742cf5594866bc747ea2/src/plugins/console/server/routes/api/console/proxy/validation_config.ts#L37-L43

Something else of note is that it looks like the website doesn't recognize `PATCH` methods either, which would most likely be an issue with code somewhere else.

Just by changing that, though, the error is just propagated through to Open Search, which ends up here:

https://github.com/opensearch-project/OpenSearch/blob/2bfe8b31af354e5d00e4ac67d544ce664f083934/server/src/main/java/org/opensearch/rest/RestController.java#L490-L506
@peternied @BSFishy 
Is anything going on? Or is it just waiting for contributions?
Waiting for a contribution, @10000-ki could you take it on?

Nice catch -- you're welcome to contribute a fix

https://github.com/opensearch-project/OpenSearch-Dashboards/blob/8ed8453af6a8dae1b2f671337dd709c711e85fd8/src/plugins/console/server/routes/api/console/proxy/validation_config.ts#L38
[Catch All Triage](https://github.com/opensearch-project/.github/blob/main/TRIAGING.md) - [1](https://github.com/krisfreedain) [2](https://github.com/andrross)

I support PATCH being added here. 
Not quite as easy as it seems...I did some groundwork here but its incompleted: https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10361

Most of the struggle is that I'm unfamiliar with the console plugin and the editor.



Fix (patch):
diff --git a/changelogs/fragments/10361.yml b/changelogs/fragments/10361.yml
new file mode 100644
index 000000000000..9d9542c42e7b
--- /dev/null
+++ b/changelogs/fragments/10361.yml
@@ -0,0 +1,2 @@
+feat:
+- Add support for PATCH in dev tools ([#10361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10361))
\ No newline at end of file
diff --git a/packages/osd-dev-utils/src/osd_client/osd_client_requester.ts b/packages/osd-dev-utils/src/osd_client/osd_client_requester.ts
index 1519497705bb..4a4c41c256d2 100644
--- a/packages/osd-dev-utils/src/osd_client/osd_client_requester.ts
+++ b/packages/osd-dev-utils/src/osd_client/osd_client_requester.ts
@@ -70,7 +70,7 @@ export interface ReqOptions {
   description?: string;
   path: string;
   query?: Record<string, any>;
-  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
+  method: 'GET' | 'PATCH' | 'POST' | 'PUT' | 'DELETE';
   body?: any;
   retries?: number;
 }
diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js
index 1f4633ab2a15..acc8f8ae9822 100644
--- a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js
+++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js
@@ -2076,12 +2076,18 @@ ace.define(
                 next('S');
                 next('T');
                 return 'POST';
+              case 'A':
+                next('A');
+                next('T');
+                next('C');
+                next('H');
+                return 'PATCH';
               default:
                 error('Unexpected \'' + ch + '\'');
             }
             break;
           default:
-            error('Expected one of GET/POST/PUT/DELETE/HEAD');
+            error('Expected one of GET/PATCH/POST/PUT/DELETE/HEAD');
         }
       },
       value, // Place holder for the value function.
@@ -2176,6 +2182,8 @@ ace.define(
         strictWhite();
         if (ch == '{') {
           object();
+        } else if (ch == '[') {
+          array();
         }
         // multi doc request
         strictWhite(); // advance to one new line
@@ -2215,7 +2223,7 @@ ace.define(
             annotate('error', e.message);
             // snap
             const substring = text.substr(at);
-            const nextMatch = substring.search(/^POST|HEAD|GET|PUT|DELETE/m);
+            const nextMatch = substring.search(/^POST|PATCH|HEAD|GET|PUT|DELETE/m);
             if (nextMatch < 1) return;
             reset(at + nextMatch);
           }
diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts
index 427578b0f8e0..ddf4974576df 100644
--- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts
+++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts
@@ -789,7 +789,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito
   }
 
   function addMethodAutoCompleteSetToContext(context: any) {
-    context.autoCompleteSet = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'].map((m, i) => ({
+    context.autoCompleteSet = ['GET', 'PATCH', 'PUT', 'POST', 'DELETE', 'HEAD'].map((m, i) => ({
       name: m,
       score: -i,
       meta: i18n.translate('console.autocomplete.addMethodMetaText', { defaultMessage: 'method' }),
diff --git a/src/plugins/console/public/lib/autocomplete/components/url_pattern_matcher.ts b/src/plugins/console/public/lib/autocomplete/components/url_pattern_matcher.ts
index f00280e3925c..6ea76ec433a9 100644
--- a/src/plugins/console/public/lib/autocomplete/components/url_pattern_matcher.ts
+++ b/src/plugins/console/public/lib/autocomplete/components/url_pattern_matcher.ts
@@ -68,7 +68,7 @@ export class UrlPatternMatcher {
     // to avoid suggesting endpoints that are incompatible with the
     // method that the user has entered.
     this.methodsData = {};
-    ['HEAD', 'GET', 'PUT', 'POST', 'DELETE'].forEach((method) => {
+    ['HEAD', 'GET', 'PATCH', 'PUT', 'POST', 'DELETE'].forEach((method) => {
       this.methodsData[method] = {
         rootComponent: new SharedComponent('ROOT'),
         parametrizedComponentFactories: parametrizedComponentFactories || {
diff --git a/src/plugins/console/public/lib/row_parser.ts b/src/plugins/console/public/lib/row_parser.ts
index a9d0ab9c9a88..a2192e830b5e 100644
--- a/src/plugins/console/public/lib/row_parser.ts
+++ b/src/plugins/console/public/lib/row_parser.ts
@@ -61,24 +61,72 @@ export default class RowParser {
     if (mode !== 'start' && mode !== 'start-sql') {
       return MODE.IN_REQUEST;
     }
+
     let line = (this.editor.getLineValue(lineNumber) || '').trim();
     if (!line || line[0] === '#') {
       return MODE.BETWEEN_REQUESTS;
     } // empty line or a comment waiting for a new req to start
 
-    if (line.indexOf('}', line.length - 1) >= 0) {
+    // ------------------------------------------------------------------
+    // A line that *starts* with "[" is the first line of a request body
+    // (e.g. PATCH with a JSON-Patch array), **not** the start of a new
+    // request.  Mark it simply as “inside a request”.
+    // ------------------------------------------------------------------
+    if (line[0] === '[') {
+      return MODE.IN_REQUEST;
+    }
+
+    /* -----------------------------------------------------------
+     * PATCH bodies are a JSON array.  While we are *inside* that
+     * array (between the opening “[” and its matching “]”) we must
+     * never consider a line starting with “{” or ending with “}”
+     * to open / close a new doc.
+     * --------------------------------------------------------- */
+    const insideJsonArray = (() => {
+      let i = lineNumber;
+      let openBrackets = 0;
+      while (i >= 1) {
+        const l = (this.editor.getLineValue(i) || '').trim();
+        if (l.startsWith(']')) {
+          if (openBrackets === 0) return false;
+          openBrackets--;
+        }
+        if (l.endsWith('[')) {
+          openBrackets++;
+        }
+        if (openBrackets > 0 && i < lineNumber) return true;
+        i--;
+      }
+      return false;
+    })();
+
+    if (insideJsonArray) {
+      return MODE.IN_REQUEST;
+    }
+
+    // If the current line ends with a closing brace/bracket, decide if the request/doc ends here.
+    const endsWithCloseBrace = line.endsWith('}');
+    const endsWithCloseBracket = /\]\s*$/.test(line);
+    if (endsWithCloseBrace || endsWithCloseBracket) {
       // check for a multi doc request (must start a new json doc immediately after this one end.
       lineNumber++;
       if (lineNumber < linesCount + 1) {
         line = (this.editor.getLineValue(lineNumber) || '').trim();
-        if (line.indexOf('{') === 0) {
+        if (line.startsWith('{')) {
           // next line is another doc in a multi doc
           // eslint-disable-next-line no-bitwise
           return MODE.MULTI_DOC_CUR_DOC_END | MODE.IN_REQUEST;
         }
+        // If next line starts with ']' we are still inside an array body,
+        // don't end the request yet — the real end is the line with ']'.
+        if (endsWithCloseBrace && line.startsWith(']')) {
+          return MODE.IN_REQUEST;
+        }
       }
+      // End the request when the current line ends with a closing bracket,
+      // or when it's a lone '}' not followed by another JSON doc.
       // eslint-disable-next-line no-bitwise
-      return MODE.REQUEST_END | MODE.MULTI_DOC_CUR_DOC_END; // end of request
+      return MODE.REQUEST_END | MODE.MULTI_DOC_CUR_DOC_END;
     }
 
     // check for single line requests
@@ -88,7 +136,9 @@ export default class RowParser {
       return MODE.REQUEST_START | MODE.REQUEST_END;
     }
     line = (this.editor.getLineValue(lineNumber) || '').trim();
-    if (line.indexOf('{') !== 0) {
+    // if the next line doesn't begin a JSON body (object or array),
+    // it means the current line is a complete request line (no body)
+    if (!(line.startsWith('{') || line.startsWith('['))) {
       // next line is another request
       // eslint-disable-next-line no-bitwise
       return MODE.REQUEST_START | MODE.REQUEST_END;
diff --git a/src/plugins/console/server/routes/api/console/proxy/validation_config.ts b/src/plugins/console/server/routes/api/console/proxy/validation_config.ts
index 7d1e9583eaf8..2dd1a2c9af3a 100644
--- a/src/plugins/console/server/routes/api/console/proxy/validation_config.ts
+++ b/src/plugins/console/server/routes/api/console/proxy/validation_config.ts
@@ -35,11 +35,11 @@ export type Body = TypeOf<typeof routeValidationConfig.body>;
 
 const acceptedHttpVerb = schema.string({
   validate: (method) => {
-    return ['HEAD', 'GET', 'POST', 'PUT', 'DELETE'].some(
+    return ['HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE'].some(
       (verb) => verb.toLowerCase() === method.toLowerCase()
     )
       ? undefined
-      : `Method must be one of, case insensitive ['HEAD', 'GET', 'POST', 'PUT', 'DELETE']. Received '${method}'.`;
+      : `Method must be one of, case insensitive ['HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE']. Received '${method}'.`;
   },
 });