Skip to content

Commit

Permalink
Merge pull request #1798 from ballerina-platform/util-fix
Browse files Browse the repository at this point in the history
Fix util issue with multipart/formdata
  • Loading branch information
lnash94 authored Dec 4, 2024
2 parents 1e5b575 + 5afd40a commit 3f5f6db
Show file tree
Hide file tree
Showing 6 changed files with 323 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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<String> invalidFunctionNames, SyntaxTree utilSyntaxTree) {
ModulePartNode modulePartNode = utilSyntaxTree.rootNode();
NodeList<ModuleMemberDeclarationNode> members = modulePartNode.members();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string|string[]> headers = {}) returns inline_response_200|error {
string resourcePath = string `/upload`;
http:Request request = new;
map<Encoding> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<any> 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<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());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -253,6 +255,8 @@ private Set<String> getFunctionNameList() {
}
if (requestBodyMultipartFormDatafound) {
functionNameList.add(CREATE_MULTIPART_BODY_PARTS);
functionNameList.add(CONSTRUCT_ENTITY);
functionNameList.add(POPULATE_ENCODING_INFO);
}
if (defaultStatusCodeResponseBindingFound) {
functionNameList.addAll(Arrays.asList(
Expand Down
94 changes: 72 additions & 22 deletions openapi-core/src/main/resources/templates/utils_openapi.bal
Original file line number Diff line number Diff line change
Expand Up @@ -231,40 +231,90 @@ isolated function getPathForQueryParam(map<anydata> queryParam, map<Encoding> en
return restOfPath;
}

isolated function createBodyParts(record {|anydata...;|} anyRecord, map<Encoding> encodingMap = {}) returns mime:Entity[]|error {
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();
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<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());
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<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());
}
}
}
}

isolated function getValidatedResponseForDefaultMapping(http:StatusCodeResponse|error response, int[] nonDefaultStatusCodes) returns http:StatusCodeResponse|error {
if response is error {
if response is http:StatusCodeResponseDataBindingError {
Expand Down

0 comments on commit 3f5f6db

Please sign in to comment.