diff --git a/openapi-cli/src/test/java/io/ballerina/openapi/generators/client/UtilGenerationTests.java b/openapi-cli/src/test/java/io/ballerina/openapi/generators/client/UtilGenerationTests.java index 6b3a29ddf..671d4bdb3 100644 --- a/openapi-cli/src/test/java/io/ballerina/openapi/generators/client/UtilGenerationTests.java +++ b/openapi-cli/src/test/java/io/ballerina/openapi/generators/client/UtilGenerationTests.java @@ -30,6 +30,7 @@ import java.util.Arrays; import java.util.List; +import static io.ballerina.openapi.generators.common.GeneratorTestUtils.compareGeneratedSyntaxTreeWithExpectedSyntaxTree; import static io.ballerina.openapi.generators.common.GeneratorTestUtils.getDiagnostics; /** @@ -210,6 +211,20 @@ public void testMultipartCustomBodyParts() throws IOException, BallerinaOpenApiE Assert.assertTrue(diagnostics.isEmpty()); } + @Test(description = "Test for generating request body and utils when operation has multipart form-data " + + "media type with array") + public void testRequestBodyWithMultipartMediaArrayType() throws IOException, BallerinaOpenApiException, + ClientException { + Path expectedClientPath = RESDIR.resolve("../ballerina/multipart_formdata_array_client.bal"); + Path expectedUtilsPath = RESDIR.resolve("../ballerina/multipart_formdata_array_utils.bal"); + Path definitionPath = RESDIR.resolve("swagger/multipart_formdata_array.yaml"); + BallerinaClientGenerator ballerinaClientGenerator = getBallerinaClientGenerator(definitionPath); + SyntaxTree clientSyntaxTree = ballerinaClientGenerator.generateSyntaxTree(); + compareGeneratedSyntaxTreeWithExpectedSyntaxTree(expectedClientPath, clientSyntaxTree); + SyntaxTree utilsSyntaxTree = ballerinaClientGenerator.getBallerinaUtilGenerator().generateUtilSyntaxTree(); + compareGeneratedSyntaxTreeWithExpectedSyntaxTree(expectedUtilsPath, utilsSyntaxTree); + } + private boolean checkUtil(List invalidFunctionNames, SyntaxTree utilSyntaxTree) { ModulePartNode modulePartNode = utilSyntaxTree.rootNode(); NodeList members = modulePartNode.members(); diff --git a/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_client.bal b/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_client.bal new file mode 100644 index 000000000..ae2cc2016 --- /dev/null +++ b/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_client.bal @@ -0,0 +1,52 @@ +import ballerina/http; +import ballerina/mime; + +# API to handle multipart form-data requests. +public isolated client class Client { + final http:Client clientEp; + # Gets invoked to initialize the `connector`. + # + # + config - The configurations to be used when initializing the `connector` + # + serviceUrl - URL of the target service + # + return - An error if connector initialization failed + public isolated function init(string serviceUrl, ConnectionConfig config = {}) returns error? { + http:ClientConfiguration httpClientConfig = {httpVersion: config.httpVersion, timeout: config.timeout, forwarded: config.forwarded, poolConfig: config.poolConfig, compression: config.compression, circuitBreaker: config.circuitBreaker, retryConfig: config.retryConfig, validation: config.validation}; + do { + if config.http1Settings is ClientHttp1Settings { + ClientHttp1Settings settings = check config.http1Settings.ensureType(ClientHttp1Settings); + httpClientConfig.http1Settings = {...settings}; + } + if config.http2Settings is http:ClientHttp2Settings { + httpClientConfig.http2Settings = check config.http2Settings.ensureType(http:ClientHttp2Settings); + } + if config.cache is http:CacheConfig { + httpClientConfig.cache = check config.cache.ensureType(http:CacheConfig); + } + if config.responseLimits is http:ResponseLimitConfigs { + httpClientConfig.responseLimits = check config.responseLimits.ensureType(http:ResponseLimitConfigs); + } + if config.secureSocket is http:ClientSecureSocket { + httpClientConfig.secureSocket = check config.secureSocket.ensureType(http:ClientSecureSocket); + } + if config.proxy is http:ProxyConfig { + httpClientConfig.proxy = check config.proxy.ensureType(http:ProxyConfig); + } + } + http:Client httpEp = check new (serviceUrl, httpClientConfig); + self.clientEp = httpEp; + return; + } + + # Upload a file with metadata and tags + # + # + headers - Headers to be sent with the request + # + return - File uploaded successfully. + resource isolated function post upload(upload_body payload, map headers = {}) returns inline_response_200|error { + string resourcePath = string `/upload`; + http:Request request = new; + map encodingMap = {"file": {contentType: "application/octet-stream"}, "metadata": {contentType: "application/json"}, "tags": {contentType: "text/plain"}}; + mime:Entity[] bodyParts = check createBodyParts(payload, encodingMap); + request.setBodyParts(bodyParts); + return self.clientEp->post(resourcePath, request, headers); + } +} diff --git a/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_utils.bal b/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_utils.bal new file mode 100644 index 000000000..694dc5141 --- /dev/null +++ b/openapi-cli/src/test/resources/generators/client/ballerina/multipart_formdata_array_utils.bal @@ -0,0 +1,117 @@ +import ballerina/url; +import ballerina/mime; + +type SimpleBasicType string|boolean|int|float|decimal; + +# Represents encoding mechanism details. +type Encoding record { + # Defines how multiple values are delimited + string style = FORM; + # Specifies whether arrays and objects should generate as separate fields + boolean explode = true; + # Specifies the custom content type + string contentType?; + # Specifies the custom headers + map headers?; +}; + +enum EncodingStyle { + DEEPOBJECT, FORM, SPACEDELIMITED, PIPEDELIMITED +} + +# Get Encoded URI for a given value. +# +# + value - Value to be encoded +# + return - Encoded string +isolated function getEncodedUri(anydata value) returns string { + string|error encoded = url:encode(value.toString(), "UTF8"); + if encoded is string { + return encoded; + } else { + return value.toString(); + } +} + +isolated function createBodyParts(record {|anydata...;|} anyRecord, map 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? headers = encoding?.headers; + if headers is map { + foreach var [headerName, headerValue] in headers.entries() { + if headerValue is SimpleBasicType { + entity.setHeader(headerName, headerValue.toString()); + } + } + } +} diff --git a/openapi-cli/src/test/resources/generators/client/utils/swagger/multipart_formdata_array.yaml b/openapi-cli/src/test/resources/generators/client/utils/swagger/multipart_formdata_array.yaml new file mode 100644 index 000000000..e30326832 --- /dev/null +++ b/openapi-cli/src/test/resources/generators/client/utils/swagger/multipart_formdata_array.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: Multipart Form-Data API + description: API to handle multipart form-data requests. + version: "1.0.0" +paths: + /upload: + post: + summary: Upload a file with metadata and tags + description: Accepts a file upload along with metadata and tags as a multipart form-data request. + operationId: uploadFile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: The file to be uploaded. + metadata: + type: object + additionalProperties: true + description: Additional metadata associated with the file. + tags: + type: array + items: + type: string + description: List of tags associated with the file. + required: + - file + encoding: + file: + contentType: application/octet-stream + style: form + metadata: + contentType: application/json + style: form + tags: + contentType: text/plain + style: form + explode: true + responses: + "200": + description: File uploaded successfully. + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: File uploaded successfully. + file: + type: string + description: The name of the uploaded file. + tags: + type: array + items: + type: string + description: List of tags associated with the uploaded file. diff --git a/openapi-core/src/main/java/io/ballerina/openapi/core/generators/client/BallerinaUtilGenerator.java b/openapi-core/src/main/java/io/ballerina/openapi/core/generators/client/BallerinaUtilGenerator.java index 0019a632b..3cf74a7af 100644 --- a/openapi-core/src/main/java/io/ballerina/openapi/core/generators/client/BallerinaUtilGenerator.java +++ b/openapi-core/src/main/java/io/ballerina/openapi/core/generators/client/BallerinaUtilGenerator.java @@ -137,6 +137,8 @@ public class BallerinaUtilGenerator { private static final String GET_PATH_FOR_QUERY_PARAM = "getPathForQueryParam"; private static final String GET_SERIALIZED_RECORD_ARRAY = "getSerializedRecordArray"; private static final String CREATE_MULTIPART_BODY_PARTS = "createBodyParts"; + private static final String CONSTRUCT_ENTITY = "constructEntity"; + private static final String POPULATE_ENCODING_INFO = "populateEncodingInfo"; private static final String GET_VALIDATED_RESPONSE_FOR_DEFAULT_MAPPING = "getValidatedResponseForDefaultMapping"; private static final String CREATE_STATUS_CODE_RESPONSE_BINDING_ERROR = "createStatusCodeResponseBindingError"; @@ -253,6 +255,8 @@ private Set getFunctionNameList() { } if (requestBodyMultipartFormDatafound) { functionNameList.add(CREATE_MULTIPART_BODY_PARTS); + functionNameList.add(CONSTRUCT_ENTITY); + functionNameList.add(POPULATE_ENCODING_INFO); } if (defaultStatusCodeResponseBindingFound) { functionNameList.addAll(Arrays.asList( diff --git a/openapi-core/src/main/resources/templates/utils_openapi.bal b/openapi-core/src/main/resources/templates/utils_openapi.bal index 9e15748a3..fada6913a 100644 --- a/openapi-core/src/main/resources/templates/utils_openapi.bal +++ b/openapi-core/src/main/resources/templates/utils_openapi.bal @@ -231,40 +231,90 @@ isolated function getPathForQueryParam(map queryParam, map en return restOfPath; } -isolated function createBodyParts(record {|anydata...;|} anyRecord, map encodingMap = {}) returns mime:Entity[]|error { +isolated function createBodyParts(record {|anydata...;|} anyRecord, map 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(); + string contentDisposition = string `form-data; name=${key};`; if value is record {byte[] fileContent; string fileName;} { - entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key}; filename=${value.fileName}`)); - entity.setByteArray(value.fileContent); + string fileContentDisposition = string `${contentDisposition} filename=${value.fileName}`; + mime:Entity entity = check constructEntity(fileContentDisposition, encodingData, + value.fileContent); + entities.push(entity); } else if value is byte[] { - entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`)); - entity.setByteArray(value); - } else if value is SimpleBasicType|SimpleBasicType[] { - entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`)); - entity.setText(value.toString()); - } else if value is record {}|record {}[] { - entity.setContentDisposition(mime:getContentDispositionObject(string `form-data; name=${key};`)); - entity.setJson(value.toJson()); - } - if encodingData?.contentType is string { - check entity.setContentType(encodingData?.contentType.toString()); - } - map? headers = encodingData?.headers; - if headers is map { - foreach var [headerName, headerValue] in headers.entries() { - if headerValue is SimpleBasicType { - entity.setHeader(headerName, headerValue.toString()); + 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); } } - 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? headers = encoding?.headers; + if headers is map { + foreach var [headerName, headerValue] in headers.entries() { + if headerValue is SimpleBasicType { + entity.setHeader(headerName, headerValue.toString()); + } + } + } +} + isolated function getValidatedResponseForDefaultMapping(http:StatusCodeResponse|error response, int[] nonDefaultStatusCodes) returns http:StatusCodeResponse|error { if response is error { if response is http:StatusCodeResponseDataBindingError {