ballerina-platform / ballerina-library

The Ballerina Library
https://ballerina.io/learn/api-docs/ballerina/
Apache License 2.0
136 stars 58 forks source link

OpenAPI generates invalid logic client generation when handling array field types with multipart/form-data #6872

Open NipunaRanasinghe opened 4 weeks ago

NipunaRanasinghe commented 4 weeks ago

Description: $subject.

Steps to reproduce:

Refer to the resource function generated in https://github.com/ballerina-platform/module-ballerinax-openai.audio/blob/c0f76de8faafb37ed394dc2ea9d51d3033240d00/ballerina/client.bal#L72

I gives the error below when trying to invoke the same resource function with valid parameters

error {ballerina/http:2}ClientRequestError&{ballerina/http:2}ClientError&{ballerina/http:2}Error&{ballerina/http:2}ApplicationResponseError ("Bad Request",statusCode=400,headers={"alt-svc":["h3=":443"; ma=86400"],"cf-cache-status":["DYNAMIC"],"cf-ray":["8b2ea533094c5134-CMB"],"content-length":["372"],"content-type":["application/json"],"date":["Wed, 14 Aug 2024 05:46:35 GMT"],"openai-organization":["wso2-10"],"openai-processing-ms":["4"],"openai-version":["2020-10-01"],"server":["cloudflare"],"set-cookie":["__cf_bm=8WpzU7IpFOxMF1CCYV30WMOETSNNqvTNp4Imn38rJhg-1723614395-1.0.1.1-iRpaIbg_vZinpVRaZ41_3FTu6H6cv49Cw6GnSBZ69Re0MImJb2sxUsnbazCtyV9L32JvULMfzRpi650_1rdpZQ; path=/; expires=Wed, 14-Aug-24 06:16:35 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None","_cfuvid=V_it4G2kYIDdqVJUDAGDyKfwdoU.00A8OUXPpoQGCL4-1723614395768-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None"],"strict-transport-security":["max-age=15552000; includeSubDomains; preload"],"x-content-type-options":["nosniff"],"x-http2-stream-id":["5"],"x-ratelimit-limit-requests":["500"],"x-ratelimit-remaining-requests":["499"],"x-ratelimit-reset-requests":["120ms"],"x-request-id":["req_5776afdfe26c40e084dfe1128504695d"]},body={"error":{"message":"1 validation error for Request
                        body -> timestamp_granularities[] -> 0
                          value is not a valid enumeration member; permitted: 'segment', 'word' (type=type_error.enum; enum_values=[<TimestampGranularity.SEGMENT: 'segment'>, <TimestampGranularity.WORD: 'word'>])","type":"invalid_request_error","param":(),"code":()}})
                                callableName: createResponseError moduleName: ballerina.http.2 fileName: http_client_endpoint.bal lineNumber: 674
                                callableName: processResponse moduleName: ballerina.http.2 fileName: http_client_endpoint.bal lineNumber: 711
                                callableName: processPost moduleName: ballerina.http.2.Client fileName: http_client_endpoint.bal lineNumber: 98

Affected Versions:

OS, DB, other environment details and versions:

Related Issues (optional):

Suggested Labels (optional):

Suggested Assignees (optional):

TharmiganK commented 3 weeks ago

The generated util function should be changed like this:

isolated function createBodyParts(record {|anydata...;|} anyRecord, map<Encoding> encodingMap = {}) returns mime:Entity[]|error {
    mime:Entity[] entities = [];
    foreach [string, anydata] [key, value] in anyRecord.entries() {
        Encoding encodingData = encodingMap.hasKey(key) ? encodingMap.get(key) : {};
        mime:Entity entity = new mime:Entity();
        if value is record {byte[] fileContent; string fileName;} {
            entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};  filename=${value.fileName}`));
            entity.setByteArray(value.fileContent);
        } else if value is byte[] {
            entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`));
            entity.setByteArray(value);
        } else if value is SimpleBasicType {
            entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`));
            entity.setText(value.toString());
        } else if value is SimpleBasicType[] {
            foreach SimpleBasicType member in value {
                entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`));
                entity.setText(member.toString());
            }
        } else if value is record {} {
            entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`));
            entity.setJson(value.toJson());
        } else if value is record {}[] {
            foreach record{} member in value {
                entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`));
                entity.setJson(member.toJson());
            }
        }
        if encodingData?.contentType is string {
            check entity.setContentType(encodingData?.contentType.toString());
        }
        map<any>? headers = encodingData?.headers;
        if headers is map<any> {
            foreach var [headerName, headerValue] in headers.entries() {
                if headerValue is SimpleBasicType {
                    entity.setHeader(headerName, headerValue.toString());
                }
            }
        }
        entities.push(entity);
    }
    return entities;
}

In addition to that, this function can be split into small parts to reduce the cognitive complexity

TharmiganK commented 3 weeks ago

There is a option to specify whether we need to pass the array value as individual key-value pairs(explode: true - default option) or a single form data with array element(explode: false). So we need to check that option before applying this change

isolated function createBodyParts(record {|anydata...;|} anyRecord, map<Encoding> encodingMap = {})
returns mime:Entity[]|error {
    mime:Entity[] entities = [];
    foreach [string, anydata] [key, value] in anyRecord.entries() {
        Encoding encodingData = encodingMap.hasKey(key) ? encodingMap.get(key) : {};
        string contentDisposition = string `form-data; name=${key};`;
        if value is record {byte[] fileContent; string fileName;} {
            string fileContentDisposition = string `${contentDisposition} filename=${value.fileName}`;
            mime:Entity entity = check constructEntity(fileContentDisposition, encodingData,
                    value.fileContent);
            entities.push(entity);
        } else if value is byte[] {
            mime:Entity entity = check constructEntity(contentDisposition, encodingData, value);
            entities.push(entity);
        } else if value is SimpleBasicType {
            mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                    value.toString());
            entities.push(entity);
        } else if value is SimpleBasicType[] {
            if encodingData.explode {
                foreach SimpleBasicType member in value {
                    mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                            member.toString());
                    entities.push(entity);
                }
            } else {
                string[] valueStrArray = from SimpleBasicType val in value
                    select val.toString();
                mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                        string:'join(",", ...valueStrArray));
                entities.push(entity);
            }
        } else if value is record {} {
            mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                    value.toString());
            entities.push(entity);
        } else if value is record {}[] {
            if encodingData.explode {
                foreach record {} member in value {
                    mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                            member.toString());
                    entities.push(entity);
                }
            } else {
                string[] valueStrArray = from record {} val in value
                    select val.toJsonString();
                mime:Entity entity = check constructEntity(contentDisposition, encodingData,
                        string:'join(",", ...valueStrArray));
                entities.push(entity);
            }
        }
    }
    return entities;
}

isolated function constructEntity(string contentDisposition, Encoding encoding,
        string|byte[]|record {} data) returns mime:Entity|error {
    mime:Entity entity = new mime:Entity();
    entity.setContentDisposition(mime:getContentDispositionObject(contentDisposition));
    if data is byte[] {
        entity.setByteArray(data);
    } else if data is string {
        entity.setText(data);
    } else {
        entity.setJson(data.toJson());
    }
    check populateEncodingInfo(entity, encoding);
    return entity;
}

isolated function populateEncodingInfo(mime:Entity entity, Encoding encoding) returns error? {
    if encoding?.contentType is string {
        check entity.setContentType(encoding?.contentType.toString());
    }
    map<any>? headers = encoding?.headers;
    if headers is map<any> {
        foreach var [headerName, headerValue] in headers.entries() {
            if headerValue is SimpleBasicType {
                entity.setHeader(headerName, headerValue.toString());
            }
        }
    }
}