Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix util issue with multipart/formdata #1798

Merged
merged 2 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`;
MohamedSabthar marked this conversation as resolved.
Show resolved Hide resolved
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
Loading