From 6edd93f99e207c3461f06ba13020a618ee121520 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 17 Apr 2025 00:11:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=BA=EF=B8=8F=20feat:=20Add=20Parameter?= =?UTF-8?q?=20Location=20Mapping=20for=20OpenAPI=20actions=20(#6858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: action parameters are assigned to the correct location (query, parameter, header, body) * removed copy/paste error * added unit tests, only add contenttype if specified --------- Co-authored-by: Peter Rothlaender --- packages/data-provider/specs/actions.spec.ts | 238 +++++++++++++++++++ packages/data-provider/src/actions.ts | 92 +++++-- 2 files changed, 311 insertions(+), 19 deletions(-) diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 10bf95a23e..34d2591023 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -206,6 +206,244 @@ describe('ActionRequest', () => { }, }); }); + + it('handles GET requests with header and query parameters', async () => { + mockedAxios.get.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'api-version': '2025-01-01', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'api-version': 'query', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/get', + 'GET', + 'testGET', + false, + '', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.get).toHaveBeenCalled(); + + const [url, config] = mockedAxios.get.mock.calls[0]; + expect(url).toBe('https://example.com/get'); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + }); + expect(config?.params).toEqual({ + 'api-version': '2025-01-01', + }); + expect(response.data.success).toBe(true); + }); + + it('handles GET requests with header and path parameters', async () => { + mockedAxios.get.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'user-id': '1', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'user-id': 'path', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/getwithpath/{user-id}', + 'GET', + 'testGETwithpath', + false, + '', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.get).toHaveBeenCalled(); + + const [url, config] = mockedAxios.get.mock.calls[0]; + expect(url).toBe('https://example.com/getwithpath/1'); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + }); + expect(config?.params).toEqual({ + }); + expect(response.data.success).toBe(true); + }); + + it('handles POST requests with body, header and query parameters', async () => { + mockedAxios.post.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'api-version': '2025-01-01', + 'message': 'a body parameter', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'api-version': 'query', + 'message': 'body', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/post', + 'POST', + 'testPost', + false, + 'application/json', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.post).toHaveBeenCalled(); + + const [url, body, config] = mockedAxios.post.mock.calls[0]; + expect(url).toBe('https://example.com/post'); + expect(body).toEqual({ message: 'a body parameter' }); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + 'Content-Type': 'application/json', + }); + expect(config?.params).toEqual({ + 'api-version': '2025-01-01', + }); + expect(response.data.success).toBe(true); + }); + + it('handles PUT requests with body, header and query parameters', async () => { + mockedAxios.put.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'api-version': '2025-01-01', + 'message': 'a body parameter', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'api-version': 'query', + 'message': 'body', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/put', + 'PUT', + 'testPut', + false, + 'application/json', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.put).toHaveBeenCalled(); + + const [url, body, config] = mockedAxios.put.mock.calls[0]; + expect(url).toBe('https://example.com/put'); + expect(body).toEqual({ message: 'a body parameter' }); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + 'Content-Type': 'application/json', + }); + expect(config?.params).toEqual({ + 'api-version': '2025-01-01', + }); + expect(response.data.success).toBe(true); + }); + + it('handles PATCH requests with body, header and query parameters', async () => { + mockedAxios.patch.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'api-version': '2025-01-01', + 'message': 'a body parameter', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'api-version': 'query', + 'message': 'body', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/patch', + 'PATCH', + 'testPatch', + false, + 'application/json', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.patch).toHaveBeenCalled(); + + const [url, body, config] = mockedAxios.patch.mock.calls[0]; + expect(url).toBe('https://example.com/patch'); + expect(body).toEqual({ message: 'a body parameter' }); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + 'Content-Type': 'application/json', + }); + expect(config?.params).toEqual({ + 'api-version': '2025-01-01', + }); + expect(response.data.success).toBe(true); + }); + + it('handles DELETE requests with body, header and query parameters', async () => { + mockedAxios.delete.mockResolvedValue({ data: { success: true } }); + + const data: Record = { + 'api-version': '2025-01-01', + 'message-id': '1', + 'some-header': 'header-var', + }; + + const loc: Record = { + 'api-version': 'query', + 'message-id': 'body', + 'some-header': 'header', + }; + + const actionRequest = new ActionRequest( + 'https://example.com', + '/delete', + 'DELETE', + 'testDelete', + false, + 'application/json', + loc, + ); + const executer = actionRequest.setParams(data); + const response = await executer.execute(); + expect(mockedAxios.delete).toHaveBeenCalled(); + + const [url, config] = mockedAxios.delete.mock.calls[0]; + expect(url).toBe('https://example.com/delete'); + expect(config?.data).toEqual({ 'message-id': '1' }); + expect(config?.headers).toEqual({ + 'some-header': 'header-var', + 'Content-Type': 'application/json', + }); + expect(config?.params).toEqual({ + 'api-version': '2025-01-01', + }); + expect(response.data.success).toBe(true); + }); + }); it('throws an error for unsupported HTTP method', async () => { diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index 8f8d5f603d..117afbd857 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -167,12 +167,13 @@ class RequestConfig { readonly operation: string, readonly isConsequential: boolean, readonly contentType: string, + readonly parameterLocations?: Record, ) {} } class RequestExecutor { path: string; - params?: object; + params?: Record; private operationHash?: string; private authHeaders: Record = {}; private authToken?: string; @@ -181,15 +182,28 @@ class RequestExecutor { this.path = config.basePath; } - setParams(params: object) { + setParams(params: Record) { this.operationHash = sha1(JSON.stringify(params)); - this.params = Object.assign({}, params); - - for (const [key, value] of Object.entries(params)) { - const paramPattern = `{${key}}`; - if (this.path.includes(paramPattern)) { - this.path = this.path.replace(paramPattern, encodeURIComponent(value as string)); - delete (this.params as Record)[key]; + this.params = { ...params } as Record; + if (this.config.parameterLocations) { + //Substituting “Path” Parameters: + for (const [key, value] of Object.entries(params)) { + if (this.config.parameterLocations[key] === 'path') { + const paramPattern = `{${key}}`; + if (this.path.includes(paramPattern)) { + this.path = this.path.replace(paramPattern, encodeURIComponent(String(value))); + delete this.params[key]; + } + } + } + } else { + // Fallback: if no locations are defined, perform path substitution for all keys. + for (const [key, value] of Object.entries(params)) { + const paramPattern = `{${key}}`; + if (this.path.includes(paramPattern)) { + this.path = this.path.replace(paramPattern, encodeURIComponent(String(value))); + delete this.params[key]; + } } } return this; @@ -275,23 +289,46 @@ class RequestExecutor { async execute() { const url = createURL(this.config.domain, this.path); - const headers = { + const headers: Record = { ...this.authHeaders, - 'Content-Type': this.config.contentType, + ...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}), }; - const method = this.config.method.toLowerCase(); const axios = _axios.create(); + + // Initialize separate containers for query and body parameters. + const queryParams: Record = {}; + const bodyParams: Record = {}; + + if (this.config.parameterLocations && this.params) { + for (const key of Object.keys(this.params)) { + // Determine parameter placement; default to "query" for GET and "body" for others. + const loc: 'query' | 'path' | 'header' | 'body' = this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body'); + + const val = this.params[key]; + if (loc === 'query') { + queryParams[key] = val; + } else if (loc === 'header') { + headers[key] = String(val); + } else if (loc === 'body') { + bodyParams[key] = val; + } + } + } else if (this.params) { + Object.assign(queryParams, this.params); + Object.assign(bodyParams, this.params); + } + if (method === 'get') { - return axios.get(url, { headers, params: this.params }); + return axios.get(url, { headers, params: queryParams }); } else if (method === 'post') { - return axios.post(url, this.params, { headers }); + return axios.post(url, bodyParams, { headers, params: queryParams }); } else if (method === 'put') { - return axios.put(url, this.params, { headers }); + return axios.put(url, bodyParams, { headers, params: queryParams }); } else if (method === 'delete') { - return axios.delete(url, { headers, data: this.params }); + return axios.delete(url, { headers, data: bodyParams, params: queryParams }); } else if (method === 'patch') { - return axios.patch(url, this.params, { headers }); + return axios.patch(url, bodyParams, { headers, params: queryParams }); } else { throw new Error(`Unsupported HTTP method: ${method}`); } @@ -312,8 +349,9 @@ export class ActionRequest { operation: string, isConsequential: boolean, contentType: string, + parameterLocations?: Record, ) { - this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType); + this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType, parameterLocations); } // Add getters to maintain backward compatibility @@ -341,7 +379,7 @@ export class ActionRequest { } // Maintain backward compatibility by delegating to a new executor - setParams(params: object) { + setParams(params: Record) { const executor = this.createExecutor(); executor.setParams(params); return executor; @@ -406,6 +444,7 @@ export function openapiToFunction( // Iterate over each path and method in the OpenAPI spec for (const [path, methods] of Object.entries(openapiSpec.paths)) { for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) { + const paramLocations: Record = {}; const operationObj = operation as OpenAPIV3.OperationObject & { 'x-openai-isConsequential'?: boolean; } & { @@ -445,6 +484,14 @@ export function openapiToFunction( if (resolvedParam.required) { parametersSchema.required.push(paramName); } + // Record the parameter location from the OpenAPI "in" field. + paramLocations[paramName] = + (resolvedParam.in === 'query' || + resolvedParam.in === 'path' || + resolvedParam.in === 'header' || + resolvedParam.in === 'body') + ? resolvedParam.in + : 'query'; } } @@ -464,6 +511,12 @@ export function openapiToFunction( if (resolvedSchema.required) { parametersSchema.required.push(...resolvedSchema.required); } + // Mark requestBody properties as belonging to the "body" + if (resolvedSchema.properties) { + for (const key in resolvedSchema.properties) { + paramLocations[key] = 'body'; + } + } } const functionSignature = new FunctionSignature( @@ -481,6 +534,7 @@ export function openapiToFunction( operationId, !!(operationObj['x-openai-isConsequential'] ?? false), operationObj.requestBody ? 'application/json' : '', + paramLocations, ); requestBuilders[operationId] = actionRequest;