🗺️ 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 () => {

View file

@ -167,12 +167,13 @@ class RequestConfig {
readonly operation: string,
readonly isConsequential: boolean,
readonly contentType: string,
readonly parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
) {}
}
class RequestExecutor {
path: string;
params?: object;
params?: Record<string, unknown>;
private operationHash?: string;
private authHeaders: Record<string, string> = {};
private authToken?: string;
@ -181,15 +182,28 @@ class RequestExecutor {
this.path = config.basePath;
}
setParams(params: object) {
setParams(params: Record<string, unknown>) {
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<string, unknown>)[key];
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)) {
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<string, string> = {
...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<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') {
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<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
@ -341,7 +379,7 @@ export class ActionRequest {
}
// Maintain backward compatibility by delegating to a new executor
setParams(params: object) {
setParams(params: Record<string, unknown>) {
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<string, 'query' | 'path' | 'header' | 'body'> = {};
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;