diff --git a/ballerina-tests/http-client-tests/tests/client_resource_method_tests.bal b/ballerina-tests/http-client-tests/tests/client_resource_method_tests.bal index 744f72d4ae..fd44f90797 100644 --- a/ballerina-tests/http-client-tests/tests/client_resource_method_tests.bal +++ b/ballerina-tests/http-client-tests/tests/client_resource_method_tests.bal @@ -120,6 +120,25 @@ service on clientResourceMethodsServerEP { } } +service /query on clientResourceMethodsServerEP { + + resource function get bar(string first\-name, string 'table, int age) returns string { + return string `Greetings! from query params: ${first\-name}, ${'table}, ${age.toString()}`; + } + + resource function get bar/[int pathParam](string first\-name, string 'table, int age) returns string { + return string `Greetings! from query with path param, query: ${first\-name}, path: ${pathParam}`; + } + + resource function post bar/[int pathParam](Person body, string first\-name, string 'table, int age) returns string { + return string `Greetings! from query with path param and request payload, query: ${first\-name}, payload.name: ${body.name}, path: ${pathParam}`; + } + + resource function put bar/[int pathParam](Person body, string first\-name, int age) returns string { + return string `Greetings! from query with different annotation, query: ${first\-name}`; + } +} + @test:Config {} function testClientGetResource() returns error? { string response = check clientResourceMethodsClientEP->/foo/bar(); @@ -280,3 +299,112 @@ function testClientResourceWithBasicRestType() returns error? { test:assertEquals(res["msg"], "Greetings! from mixed path params"); test:assertEquals(res["path"], "/bar/foo/45/34.5/45.6/true"); } + +public type QueryParams record {| + @http:Query {name: "first-name"} + string first\-Name; + @http:Query {name: "table"} + string tableNo; + @http:Query {name: "age"} + int personAge; +|}; + + +public type MetaInfo record {| + string name; +|}; +public const annotation MetaInfo Meta on record field; +public type QueryWithDifferentAnnotation record {| + @http:Query {name: "first-name"} + @Meta { + name: "Potter" + } + string firstName; + int age; +|}; + +@test:Config {} +function testQueryParametersNameOverride() returns error? { + QueryParams queries = { + first\-Name: "Jhon", + tableNo: "10", + personAge: 29 + }; + + string response = check clientResourceMethodsClientEP->/query/bar.get(params = queries); + test:assertEquals(response, "Greetings! from query params: Jhon, 10, 29"); + + response = check clientResourceMethodsClientEP->/query/bar/[99].get(params = queries); + test:assertEquals(response, "Greetings! from query with path param, query: Jhon, path: 99"); + + Person person = { + name: "Harry", + age: 29 + }; + + response = check clientResourceMethodsClientEP->/query/bar/[99].post(person, params = queries); + test:assertEquals(response, "Greetings! from query with path param and request payload, query: Jhon, payload.name: Harry, path: 99"); + + QueryWithDifferentAnnotation annotQ = { + firstName: "Ron", + age: 29 + }; + response = check clientResourceMethodsClientEP->/query/bar/[99].put(person, params = annotQ); + test:assertEquals(response, "Greetings! from query with different annotation, query: Ron"); +} + +public type EmptyName record {| + @http:Query {name: ""} + string firstName; + int age; +|}; + +public type NoName record {| + @http:Query {} + string firstName; + int age; +|}; + +public type NilName record {| + @http:Query {name: ()} + string firstName; + int age; +|}; + +@test:Config +function testNegativeQueryParametersNameOverride() returns error? { + EmptyName emptyName = { + firstName: "Ron", + age: 29 + }; + Person person = { + name: "Harry", + age: 29 + }; + + //with empty name + http:Response response = check clientResourceMethodsClientEP->/query/bar/[99].put(person, params = emptyName); + test:assertEquals(response.statusCode, 400); + common:assertHeaderValue(check response.getHeader(common:CONTENT_TYPE), common:APPLICATION_JSON); + check common:assertJsonErrorPayloadPartialMessage(check response.getJsonPayload(), "no query param value found for 'first-name'"); + + // with no name attribute + NoName noName = { + firstName: "Ron", + age: 29 + }; + response = check clientResourceMethodsClientEP->/query/bar/[99].put(person, params = noName); + test:assertEquals(response.statusCode, 400); + common:assertHeaderValue(check response.getHeader(common:CONTENT_TYPE), common:APPLICATION_JSON); + check common:assertJsonErrorPayloadPartialMessage(check response.getJsonPayload(), "no query param value found for 'first-name'"); + + // with nil name value + NilName nilName = { + firstName: "Ron", + age: 29 + }; + response = check clientResourceMethodsClientEP->/query/bar/[99].put(person, params = nilName); + test:assertEquals(response.statusCode, 400); + common:assertHeaderValue(check response.getHeader(common:CONTENT_TYPE), common:APPLICATION_JSON); + check common:assertJsonErrorPayloadPartialMessage(check response.getJsonPayload(), "no query param value found for 'first-name'"); +} diff --git a/ballerina/http_annotation.bal b/ballerina/http_annotation.bal index 29ebeb299e..3319c0dae0 100644 --- a/ballerina/http_annotation.bal +++ b/ballerina/http_annotation.bal @@ -115,10 +115,14 @@ public type HttpHeader record {| public annotation HttpHeader Header on parameter; # Defines the query resource signature parameter. -public type HttpQuery record {||}; +# +# + name - Specifies the name of the query parameter +public type HttpQuery record {| + string name?; +|}; # The annotation which is used to define the query resource signature parameter. -public annotation HttpQuery Query on parameter; +public const annotation HttpQuery Query on parameter, record field; # Defines the HTTP response cache configuration. By default the `no-cache` directive is setted to the `cache-control` # header. In addition to that `etag` and `last-modified` headers are also added for cache validation. diff --git a/changelog.md b/changelog.md index 0e061b132d..83dd8125fb 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - [Add `anydata` support for `setPayload` methods in the request and response objects](https://github.com/ballerina-platform/ballerina-library/issues/6954) +- [Improve `@http:Query` annotation to overwrite the query parameter name in client] (https://github.com/ballerina-platform/ballerina-library/issues/6983) ## [2.12.0] - 2024-08-20 diff --git a/native/src/main/java/io/ballerina/stdlib/http/api/HttpConstants.java b/native/src/main/java/io/ballerina/stdlib/http/api/HttpConstants.java index 0f90f4c859..03ec1f37ef 100644 --- a/native/src/main/java/io/ballerina/stdlib/http/api/HttpConstants.java +++ b/native/src/main/java/io/ballerina/stdlib/http/api/HttpConstants.java @@ -159,6 +159,7 @@ public final class HttpConstants { public static final BString ANN_FIELD_RESPOND_TYPE = StringUtils.fromString("respondType"); public static final BString ANN_FIELD_NAME = StringUtils.fromString("name"); public static final String ANN_NAME_CACHE = "Cache"; + public static final String ANN_NAME_QUERY = "Query"; public static final String VALUE_ATTRIBUTE = "value"; @@ -628,6 +629,8 @@ public final class HttpConstants { public static final String HTTP_VERSION_1_1 = "1.1"; public static final String CURRENT_TRANSACTION_CONTEXT_PROPERTY = "currentTrxContext"; + public static final String REGEX_FOR_FIELD = "(\\$field\\$\\.)"; + public static final String ESCAPE_SLASH = "\\\\"; @Deprecated public static final String HTTP_MODULE_VERSION = "1.0.6"; diff --git a/native/src/main/java/io/ballerina/stdlib/http/api/client/actions/HttpClientAction.java b/native/src/main/java/io/ballerina/stdlib/http/api/client/actions/HttpClientAction.java index 819a462be4..7533bb6208 100644 --- a/native/src/main/java/io/ballerina/stdlib/http/api/client/actions/HttpClientAction.java +++ b/native/src/main/java/io/ballerina/stdlib/http/api/client/actions/HttpClientAction.java @@ -23,6 +23,7 @@ import io.ballerina.runtime.api.PredefinedTypes; import io.ballerina.runtime.api.TypeTags; import io.ballerina.runtime.api.async.Callback; +import io.ballerina.runtime.api.types.RecordType; import io.ballerina.runtime.api.utils.StringUtils; import io.ballerina.runtime.api.values.BArray; import io.ballerina.runtime.api.values.BError; @@ -34,6 +35,7 @@ import io.ballerina.stdlib.http.api.HttpConstants; import io.ballerina.stdlib.http.api.HttpErrorType; import io.ballerina.stdlib.http.api.HttpUtil; +import io.ballerina.stdlib.http.api.nativeimpl.ModuleUtils; import io.ballerina.stdlib.http.transport.contract.HttpClientConnector; import io.ballerina.stdlib.http.transport.message.Http2PushPromise; import io.ballerina.stdlib.http.transport.message.HttpCarbonMessage; @@ -44,20 +46,26 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static io.ballerina.runtime.observability.ObservabilityConstants.KEY_OBSERVER_CONTEXT; import static io.ballerina.stdlib.http.api.HttpConstants.AND_SIGN; +import static io.ballerina.stdlib.http.api.HttpConstants.ANN_NAME_QUERY; import static io.ballerina.stdlib.http.api.HttpConstants.CLIENT_ENDPOINT_CONFIG; import static io.ballerina.stdlib.http.api.HttpConstants.CLIENT_ENDPOINT_SERVICE_URI; +import static io.ballerina.stdlib.http.api.HttpConstants.COLON; import static io.ballerina.stdlib.http.api.HttpConstants.CURRENT_TRANSACTION_CONTEXT_PROPERTY; import static io.ballerina.stdlib.http.api.HttpConstants.EMPTY; import static io.ballerina.stdlib.http.api.HttpConstants.EQUAL_SIGN; +import static io.ballerina.stdlib.http.api.HttpConstants.ESCAPE_SLASH; import static io.ballerina.stdlib.http.api.HttpConstants.INBOUND_MESSAGE; import static io.ballerina.stdlib.http.api.HttpConstants.MAIN_STRAND; import static io.ballerina.stdlib.http.api.HttpConstants.ORIGIN_HOST; import static io.ballerina.stdlib.http.api.HttpConstants.POOLED_BYTE_BUFFER_FACTORY; import static io.ballerina.stdlib.http.api.HttpConstants.QUESTION_MARK; import static io.ballerina.stdlib.http.api.HttpConstants.QUOTATION_MARK; +import static io.ballerina.stdlib.http.api.HttpConstants.REGEX_FOR_FIELD; import static io.ballerina.stdlib.http.api.HttpConstants.REMOTE_ADDRESS; import static io.ballerina.stdlib.http.api.HttpConstants.SINGLE_SLASH; import static io.ballerina.stdlib.http.api.HttpConstants.SRC_HANDLER; @@ -293,22 +301,67 @@ private static String[] getPathStringArray(BArray pathArray) { private static String constructQueryString(BMap params) { List queryParams = new ArrayList<>(); + Map annotationValues = getQueryNameMapping(params); BString[] keys = (BString[]) params.getKeys(); if (keys.length == 0) { return ""; } for (BString key : keys) { Object value = params.get(key); + String queryName = key.getValue(); + queryName = annotationValues.getOrDefault(queryName, queryName); String valueString = value.toString(); if (value instanceof BArray) { valueString = valueString.substring(1, valueString.length() - 1); valueString = valueString.replace(QUOTATION_MARK, EMPTY); } - queryParams.add(key.getValue() + EQUAL_SIGN + valueString); + queryParams.add(queryName + EQUAL_SIGN + valueString); } return String.join(AND_SIGN, queryParams); } + /** + * This util function extracts the query name with the query annotation. + * + * @param params - Parameter map + * @return Map of string with overridden query param names + */ + private static Map getQueryNameMapping(BMap params) { + Map annotationValues = new HashMap<>(); + RecordType queryRecord = (RecordType) params.getType(); + BMap queryFields = queryRecord.getAnnotations(); + + for (Map.Entry qField: queryFields.entrySet()) { + BMap value = (BMap) qField.getValue(); + Object[] keys = value.getKeys(); + for (Object annotRef: keys) { + String refRegex = ModuleUtils.getHttpPackageIdentifier() + COLON + ANN_NAME_QUERY; + Pattern pattern = Pattern.compile(refRegex); + Matcher matcher = pattern.matcher(annotRef.toString()); + if (matcher.find()) { + BMap refValue = (BMap) value.get(annotRef); + extractedFieldName(annotationValues, qField, refValue); + } + } + } + return annotationValues; + } + + private static void extractedFieldName(Map annotationValues, Map.Entry qField, + BMap value) { + String[] parts = Pattern.compile(REGEX_FOR_FIELD).split(qField.getKey().getValue()); + String fieldName = unescapeIdentifier(parts[1]); + Object overrideValue = value.get(HttpConstants.ANN_FIELD_NAME); + if (!(overrideValue instanceof BString overrideName)) { + return; + } + annotationValues.put(fieldName, overrideName.getValue()); + } + + public static String unescapeIdentifier(String parameterName) { + return parameterName.replaceAll(ESCAPE_SLASH, EMPTY); + } + private HttpClientAction() { } }