🗺️ feat: Add Parameter Location Mapping for OpenAPI actions (#6858)

* 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 <peter.rothlaender@ginkgo.com>
This commit is contained in:
Peter 2025-04-17 00:11:03 +02:00 committed by GitHub
parent 16aa5ed466
commit 6edd93f99e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 311 additions and 19 deletions

View file

@ -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<string, unknown> = {
'api-version': '2025-01-01',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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<string, unknown> = {
'user-id': '1',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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<string, unknown> = {
'api-version': '2025-01-01',
'message': 'a body parameter',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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<string, unknown> = {
'api-version': '2025-01-01',
'message-id': '1',
'some-header': 'header-var',
};
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
'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 () => { it('throws an error for unsupported HTTP method', async () => {

View file

@ -167,12 +167,13 @@ class RequestConfig {
readonly operation: string, readonly operation: string,
readonly isConsequential: boolean, readonly isConsequential: boolean,
readonly contentType: string, readonly contentType: string,
readonly parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
) {} ) {}
} }
class RequestExecutor { class RequestExecutor {
path: string; path: string;
params?: object; params?: Record<string, unknown>;
private operationHash?: string; private operationHash?: string;
private authHeaders: Record<string, string> = {}; private authHeaders: Record<string, string> = {};
private authToken?: string; private authToken?: string;
@ -181,15 +182,28 @@ class RequestExecutor {
this.path = config.basePath; this.path = config.basePath;
} }
setParams(params: object) { setParams(params: Record<string, unknown>) {
this.operationHash = sha1(JSON.stringify(params)); this.operationHash = sha1(JSON.stringify(params));
this.params = Object.assign({}, params); this.params = { ...params } as Record<string, unknown>;
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)) { for (const [key, value] of Object.entries(params)) {
const paramPattern = `{${key}}`; const paramPattern = `{${key}}`;
if (this.path.includes(paramPattern)) { if (this.path.includes(paramPattern)) {
this.path = this.path.replace(paramPattern, encodeURIComponent(value as string)); this.path = this.path.replace(paramPattern, encodeURIComponent(String(value)));
delete (this.params as Record<string, unknown>)[key]; delete this.params[key];
}
} }
} }
return this; return this;
@ -275,23 +289,46 @@ class RequestExecutor {
async execute() { async execute() {
const url = createURL(this.config.domain, this.path); const url = createURL(this.config.domain, this.path);
const headers = { const headers: Record<string, string> = {
...this.authHeaders, ...this.authHeaders,
'Content-Type': this.config.contentType, ...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}),
}; };
const method = this.config.method.toLowerCase(); const method = this.config.method.toLowerCase();
const axios = _axios.create(); const axios = _axios.create();
// Initialize separate containers for query and body parameters.
const queryParams: Record<string, unknown> = {};
const bodyParams: Record<string, unknown> = {};
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') { if (method === 'get') {
return axios.get(url, { headers, params: this.params }); return axios.get(url, { headers, params: queryParams });
} else if (method === 'post') { } else if (method === 'post') {
return axios.post(url, this.params, { headers }); return axios.post(url, bodyParams, { headers, params: queryParams });
} else if (method === 'put') { } else if (method === 'put') {
return axios.put(url, this.params, { headers }); return axios.put(url, bodyParams, { headers, params: queryParams });
} else if (method === 'delete') { } 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') { } else if (method === 'patch') {
return axios.patch(url, this.params, { headers }); return axios.patch(url, bodyParams, { headers, params: queryParams });
} else { } else {
throw new Error(`Unsupported HTTP method: ${method}`); throw new Error(`Unsupported HTTP method: ${method}`);
} }
@ -312,8 +349,9 @@ export class ActionRequest {
operation: string, operation: string,
isConsequential: boolean, isConsequential: boolean,
contentType: string, contentType: string,
parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
) { ) {
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 // Add getters to maintain backward compatibility
@ -341,7 +379,7 @@ export class ActionRequest {
} }
// Maintain backward compatibility by delegating to a new executor // Maintain backward compatibility by delegating to a new executor
setParams(params: object) { setParams(params: Record<string, unknown>) {
const executor = this.createExecutor(); const executor = this.createExecutor();
executor.setParams(params); executor.setParams(params);
return executor; return executor;
@ -406,6 +444,7 @@ export function openapiToFunction(
// Iterate over each path and method in the OpenAPI spec // Iterate over each path and method in the OpenAPI spec
for (const [path, methods] of Object.entries(openapiSpec.paths)) { for (const [path, methods] of Object.entries(openapiSpec.paths)) {
for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) { for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) {
const paramLocations: Record<string, 'query' | 'path' | 'header' | 'body'> = {};
const operationObj = operation as OpenAPIV3.OperationObject & { const operationObj = operation as OpenAPIV3.OperationObject & {
'x-openai-isConsequential'?: boolean; 'x-openai-isConsequential'?: boolean;
} & { } & {
@ -445,6 +484,14 @@ export function openapiToFunction(
if (resolvedParam.required) { if (resolvedParam.required) {
parametersSchema.required.push(paramName); 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) { if (resolvedSchema.required) {
parametersSchema.required.push(...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( const functionSignature = new FunctionSignature(
@ -481,6 +534,7 @@ export function openapiToFunction(
operationId, operationId,
!!(operationObj['x-openai-isConsequential'] ?? false), !!(operationObj['x-openai-isConsequential'] ?? false),
operationObj.requestBody ? 'application/json' : '', operationObj.requestBody ? 'application/json' : '',
paramLocations,
); );
requestBuilders[operationId] = actionRequest; requestBuilders[operationId] = actionRequest;