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

KOGITO-8410 Added support for GET method to Knative custom function #3172

Merged
merged 4 commits into from
Aug 23, 2023
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 @@ -23,13 +23,15 @@
import org.jbpm.ruleflow.core.RuleFlowNodeContainerFactory;
import org.jbpm.ruleflow.core.factory.NodeFactory;
import org.jbpm.ruleflow.core.factory.WorkItemNodeFactory;
import org.jetbrains.annotations.NotNull;
import org.kie.kogito.addons.quarkus.knative.serving.customfunctions.CloudEventKnativeParamsDecorator;
import org.kie.kogito.addons.quarkus.knative.serving.customfunctions.KnativeWorkItemHandler;
import org.kie.kogito.addons.quarkus.knative.serving.customfunctions.Operation;
import org.kie.kogito.addons.quarkus.knative.serving.customfunctions.PlainJsonKnativeParamsDecorator;
import org.kie.kogito.serverless.workflow.parser.ParserContext;
import org.kie.kogito.serverless.workflow.parser.VariableInfo;
import org.kie.kogito.serverless.workflow.parser.types.WorkItemTypeHandler;
import org.kie.kogito.serverless.workflow.suppliers.CollectionParamsDecoratorSupplier;
import org.kie.kogito.serverless.workflow.suppliers.ParamsRestBodyBuilderSupplier;
import org.kogito.workitem.rest.RestWorkItemHandler;

Expand Down Expand Up @@ -57,41 +59,53 @@ public class KnativeTypeHandler extends WorkItemTypeHandler {
WorkItemNodeFactory<?> node = buildWorkItem(embeddedSubProcess, context, varInfo.getInputVar(), varInfo.getOutputVar())
.name(functionDef.getName());

if (functionRef.getArguments() != null && !functionRef.getArguments().isEmpty()) {
List<String> payloadFields = new ArrayList<>();
functionRef.getArguments().fieldNames().forEachRemaining(payloadFields::add);
List<String> payloadFields = getPayloadFields(functionRef);

Operation operation = Operation.parse(trimCustomOperation(functionDef));

if (HttpMethod.GET.equals(operation.getHttpMethod())) {
node.workParameter(RestWorkItemHandler.PARAMS_DECORATOR, new CollectionParamsDecoratorSupplier(List.of(), payloadFields));
} else {
if (!payloadFields.isEmpty()) {
node.workParameter(PAYLOAD_FIELDS_PROPERTY_NAME, String.join(";", payloadFields));
node.workParameter(PAYLOAD_FIELDS_PROPERTY_NAME, payloadFields);
}
if (operation.isCloudEvent()) {
node.workParameter(RestWorkItemHandler.PARAMS_DECORATOR, CloudEventKnativeParamsDecorator.class.getName());
} else {
node.workParameter(RestWorkItemHandler.PARAMS_DECORATOR, PlainJsonKnativeParamsDecorator.class.getName());
}
}

node.workParameter(KnativeWorkItemHandler.SERVICE_PROPERTY_NAME, operation.getService())
.workParameter(KnativeWorkItemHandler.PATH_PROPERTY_NAME, operation.getPath())
.workParameter(RestWorkItemHandler.METHOD, operation.getHttpMethod());

return addFunctionArgs(workflow,
fillWorkItemHandler(workflow, context, node, functionDef),
functionRef);
}

@NotNull
private static List<String> getPayloadFields(FunctionRef functionRef) {
List<String> payloadFields = new ArrayList<>();

if (functionRef.getArguments() != null && !functionRef.getArguments().isEmpty()) {
functionRef.getArguments().fieldNames().forEachRemaining(payloadFields::add);
}
return payloadFields;
}

@Override
protected <T extends RuleFlowNodeContainerFactory<T, ?>> WorkItemNodeFactory<T> fillWorkItemHandler(
Workflow workflow, ParserContext context, WorkItemNodeFactory<T> node, FunctionDefinition functionDef) {
if (functionDef.getMetadata() != null) {
functionDef.getMetadata().forEach(node::metaData);
}

Operation operation = Operation.parse(trimCustomOperation(functionDef));

if (operation.isCloudEvent()) {
node.workParameter(RestWorkItemHandler.PARAMS_DECORATOR, CloudEventKnativeParamsDecorator.class.getName());
} else {
node.workParameter(RestWorkItemHandler.PARAMS_DECORATOR, PlainJsonKnativeParamsDecorator.class.getName());
}

Supplier<Expression> requestTimeout = runtimeRestApi(functionDef, "timeout",
context.getContext(), String.class, DEFAULT_REQUEST_TIMEOUT_VALUE);

return node.workParameter(KnativeWorkItemHandler.SERVICE_PROPERTY_NAME, operation.getService())
.workParameter(KnativeWorkItemHandler.PATH_PROPERTY_NAME, operation.getPath())
.workParameter(RestWorkItemHandler.BODY_BUILDER, new ParamsRestBodyBuilderSupplier())
.workParameter(RestWorkItemHandler.METHOD, HttpMethod.POST)
return node.workParameter(RestWorkItemHandler.BODY_BUILDER, new ParamsRestBodyBuilderSupplier())
.workParameter(RestWorkItemHandler.REQUEST_TIMEOUT_IN_MILLIS, requestTimeout)
.metaData(TaskDescriptor.KEY_WORKITEM_TYPE, RestWorkItemHandler.REST_TASK_TYPE)
.workName(KnativeWorkItemHandler.NAME);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"id": "getKnativeFunction",
"version": "1.0",
"name": "Test Knative function",
"description": "This workflow tests a Knative function",
"start": "invokeFunction",
"functions": [
{
"name": "greet",
"type": "custom",
"operation": "knative:services.v1.serving.knative.dev/default/serverless-workflow-greeting-quarkus?path=/plainJsonFunction&method=GET"
}
],
"states": [
{
"name": "invokeFunction",
"type": "operation",
"actions": [
{
"functionRef": {
"refName": "greet",
"arguments": {
"name": ".name"
}
}
}
],
"end": true
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
Expand Down Expand Up @@ -85,6 +87,22 @@ static void afterAll() {
}
}

@Test
void executeHttpGet() {
mockExecuteHttpGetEndpoint();

given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body("{\"name\": \"hbelmiro\" }").when()
.post("/getKnativeFunction")
.then()
.statusCode(HttpURLConnection.HTTP_CREATED)
.body("workflowdata.message", is("Hello"));

wireMockServer.verify(getRequestedFor(urlEqualTo("/plainJsonFunction?name=hbelmiro")));
}

@Test
void executeWithEmptyParameters() {
mockExecuteWithEmptyParametersEndpoint();
Expand Down Expand Up @@ -306,6 +324,15 @@ private void mockExecuteWithParametersEndpoint() {
.put("message", "Hello"))));
}

private void mockExecuteHttpGetEndpoint() {
wireMockServer.stubFor(get(urlEqualTo("/plainJsonFunction?name=hbelmiro"))
.willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_OK)
.withHeader("Content-Type", "application/json")
.withJsonBody(JsonNodeFactory.instance.objectNode()
.put("message", "Hello"))));
}

private void mockExecuteWithArrayEndpoint() {
wireMockServer.stubFor(post(urlEqualTo("/arrayFunction"))
.withRequestBody(equalToJson(JsonNodeFactory.instance.objectNode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@
*/
package org.kie.kogito.addons.quarkus.knative.serving.customfunctions;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;

import static java.util.stream.Collectors.toMap;
import static org.kie.kogito.addons.quarkus.knative.serving.customfunctions.KnativeWorkItemHandler.PAYLOAD_FIELDS_DELIMITER;
import static org.kie.kogito.addons.quarkus.knative.serving.customfunctions.KnativeWorkItemHandler.PAYLOAD_FIELDS_PROPERTY_NAME;

final class KnativeFunctionPayloadSupplier {
Expand All @@ -35,11 +34,8 @@ static Map<String, Object> getPayload(Map<String, Object> parameters) {
}

private static List<String> getPayloadFields(Map<String, Object> parameters) {
String payloadFields = (String) parameters.remove(PAYLOAD_FIELDS_PROPERTY_NAME);
if (payloadFields != null) {
return Arrays.asList(payloadFields.split(PAYLOAD_FIELDS_DELIMITER));
} else {
return List.of();
}
@SuppressWarnings("unchecked")
List<String> payloadFields = (List<String>) parameters.remove(PAYLOAD_FIELDS_PROPERTY_NAME);
return Objects.requireNonNullElseGet(payloadFields, List::of);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ public final class KnativeWorkItemHandler extends RestWorkItemHandler {

public static final String PAYLOAD_FIELDS_PROPERTY_NAME = "knative_function_payload_fields";

public static final String PAYLOAD_FIELDS_DELIMITER = ";";

public static final String CLOUDEVENT_SENT_AS_PLAIN_JSON_ERROR_MESSAGE = "A Knative custom function argument cannot be a CloudEvent when the 'asCloudEvent' property are not set to 'true'";

private final KubernetesServiceCatalog kubernetesServiceCatalog;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,51 @@
*/
package org.kie.kogito.addons.quarkus.knative.serving.customfunctions;

import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import io.vertx.core.http.HttpMethod;

public final class Operation {

private static final Set<HttpMethod> SUPPORTED_METHODS = Set.of(HttpMethod.POST, HttpMethod.GET);

private static final HttpMethod DEFAULT_HTTP_METHOD = HttpMethod.POST;

static final String CLOUD_EVENT_PARAMETER_NAME = "asCloudEvent";

static final String PATH_PARAMETER_NAME = "path";

static final String METHOD_PARAMETER_NAME = "method";

private final String service;

private final String path;

private final boolean isCloudEvent;

private final HttpMethod httpMethod;

private Operation(Builder builder) {
this.service = Objects.requireNonNull(builder.service);
this.path = builder.path != null ? builder.path : "/";
this.isCloudEvent = builder.isCloudEvent;
this.httpMethod = builder.httpMethod;
validate(this);
}

private static void validate(Operation operation) {
if (!SUPPORTED_METHODS.contains(operation.getHttpMethod())) {
throw new UnsupportedOperationException(
MessageFormat.format("Knative custom function doesn''t support the {0} HTTP method. Supported methods are: {1}.", operation.getHttpMethod(), SUPPORTED_METHODS));
}

if (operation.isCloudEvent() && !operation.getHttpMethod().equals(HttpMethod.POST)) {
throw new UnsupportedOperationException(MessageFormat.format("Knative custom function can only send CloudEvents through POST method. Method used: {0}", operation.getHttpMethod()));
}
}

public String getService() {
Expand All @@ -49,6 +74,10 @@ public boolean isCloudEvent() {
return isCloudEvent;
}

public HttpMethod getHttpMethod() {
return httpMethod;
}

public static Operation parse(String value) {
String[] parts = value.split("\\?", 2);

Expand All @@ -61,8 +90,9 @@ public static Operation parse(String value) {

return builder()
.withService(parts[0])
.withPath(params.get("path"))
.withPath(params.get(PATH_PARAMETER_NAME))
.withIsCloudEvent(Boolean.parseBoolean(params.get(CLOUD_EVENT_PARAMETER_NAME)))
.withMethod(HttpMethod.valueOf(params.getOrDefault(METHOD_PARAMETER_NAME, DEFAULT_HTTP_METHOD.name()).toUpperCase()))
.build();
}

Expand All @@ -81,12 +111,23 @@ public boolean equals(Object o) {
Operation operation = (Operation) o;
return isCloudEvent == operation.isCloudEvent
&& Objects.equals(service, operation.service)
&& Objects.equals(path, operation.path);
&& Objects.equals(path, operation.path)
&& Objects.equals(httpMethod, operation.httpMethod);
}

@Override
public String toString() {
return "Operation{" +
"service='" + service + '\'' +
", path='" + path + '\'' +
", isCloudEvent=" + isCloudEvent +
", httpMethod=" + httpMethod +
'}';
}

@Override
public int hashCode() {
return Objects.hash(service, path, isCloudEvent);
return Objects.hash(service, path, isCloudEvent, httpMethod);
}

public static class Builder {
Expand All @@ -97,6 +138,8 @@ public static class Builder {

private boolean isCloudEvent;

private HttpMethod httpMethod = DEFAULT_HTTP_METHOD;

private Builder() {
}

Expand All @@ -115,6 +158,11 @@ public Builder withIsCloudEvent(boolean isCloudEvent) {
return this;
}

public Builder withMethod(HttpMethod httpMethod) {
this.httpMethod = httpMethod;
return this;
}

public Operation build() {
return new Operation(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,55 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import io.vertx.core.http.HttpMethod;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
import static org.kie.kogito.addons.quarkus.knative.serving.customfunctions.Operation.CLOUD_EVENT_PARAMETER_NAME;
import static org.kie.kogito.addons.quarkus.knative.serving.customfunctions.Operation.METHOD_PARAMETER_NAME;
import static org.kie.kogito.addons.quarkus.knative.serving.customfunctions.Operation.PATH_PARAMETER_NAME;

class OperationTest {

public static final String SERVICE = "service";

public static Stream<Arguments> parseSource() {
return Stream.of(
Arguments.of("service", Operation.builder().withService("service").build()),
Arguments.of(SERVICE, Operation.builder().withService(SERVICE).build()),

Arguments.of("service?", Operation.builder().withService(SERVICE).build()),

Arguments.of("service?", Operation.builder().withService("service").build()),
Arguments.of("service?" + PATH_PARAMETER_NAME + "=/my_path", Operation.builder().withService(SERVICE).withPath("/my_path").build()),

Arguments.of("service?" + PATH_PARAMETER_NAME + "=/my_path", Operation.builder().withService("service").withPath("/my_path").build()),
Arguments.of("service?" + CLOUD_EVENT_PARAMETER_NAME + "=true", Operation.builder().withService(SERVICE).withIsCloudEvent(true).build()),

Arguments.of("service?" + CLOUD_EVENT_PARAMETER_NAME + "=true", Operation.builder().withService("service").withIsCloudEvent(true).build()),
Arguments.of("service?" + METHOD_PARAMETER_NAME + "=GET", Operation.builder().withService(SERVICE).withMethod(HttpMethod.GET).build()),

Arguments.of("service?" + METHOD_PARAMETER_NAME + "=get", Operation.builder().withService(SERVICE).withMethod(HttpMethod.GET).build()),

Arguments.of("service?" + PATH_PARAMETER_NAME + "=/my_path&" + CLOUD_EVENT_PARAMETER_NAME + "=true",
Operation.builder().withService("service").withPath("/my_path").withIsCloudEvent(true).build()));
Operation.builder().withService(SERVICE).withPath("/my_path").withIsCloudEvent(true).build()),

Arguments.of("service?" + PATH_PARAMETER_NAME + "=/my_path&" + CLOUD_EVENT_PARAMETER_NAME + "=false&" + METHOD_PARAMETER_NAME + "=GET",
Operation.builder().withService(SERVICE).withPath("/my_path").withIsCloudEvent(false).withMethod(HttpMethod.GET).build()));
}

public static Stream<Arguments> invalidOperationSource() {
return Stream.of(
Arguments.of(Operation.builder().withService(SERVICE).withMethod(HttpMethod.DELETE)),
Arguments.of(Operation.builder().withService(SERVICE).withIsCloudEvent(true).withMethod(HttpMethod.GET)));
}

@ParameterizedTest
@MethodSource("parseSource")
void parse(String operationValue, Operation expectedOperation) {
assertThat(Operation.parse(operationValue)).isEqualTo(expectedOperation);
}
}

@ParameterizedTest
@MethodSource("invalidOperationSource")
void invalidOperation(Operation.Builder operationBuilder) {
assertThatExceptionOfType(UnsupportedOperationException.class)
.isThrownBy(operationBuilder::build);
}
}
Loading
Loading