From f51fdaf1e45ea1184767846a628f46c6d19b69f6 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti Date: Fri, 13 Nov 2026 13:30:09 +0100 Subject: [PATCH 1/2] [Fix #1829] Support HttpCall output property It also support http request & response additional content types Signed-off-by: Francisco Javier Tirado Sarti --- .../sdk/impl/WorkflowModelFactory.java | 6 + .../sdk/impl/auth/DigestAuthProvider.java | 2 +- .../http/CallableTaskHttpExecutorBuilder.java | 29 ++-- .../sdk/impl/executors/http/HttpExecutor.java | 9 +- .../sdk/impl/http/DefaultHttpRequest.java | 41 +++--- .../sdk/impl/http/DefaultHttpResponse.java | 97 ++++++++++++- .../impl/http/WorkflowHttpContentType.java | 26 ++-- .../impl/http/WorkflowHttpRequestHelper.java | 133 ++++++++++++++++-- .../sdk/impl/http/WorkflowHttpResponse.java | 20 ++- .../jackson/JacksonModelConverterFactory.java | 5 +- .../http/DefaultHttpModelConverter.java | 36 +++++ .../jakarta/http/HttpConverterResolver.java | 8 +- .../impl/jakarta/http/HttpModelConverter.java | 10 +- .../jakarta/http/JakartaHttpResponse.java | 59 +++++--- .../jakarta/http/JakartaRequestExecutor.java | 43 +++--- .../model/jackson/JacksonModelFactory.java | 5 + .../impl/test/HTTPWorkflowDefinitionTest.java | 97 +++++++++++++ .../call-http-get-headers.yaml | 22 +++ 18 files changed, 538 insertions(+), 110 deletions(-) create mode 100644 impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/DefaultHttpModelConverter.java create mode 100644 impl/test/src/test/resources/workflows-samples/call-http-get-headers.yaml diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/WorkflowModelFactory.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/WorkflowModelFactory.java index 920de6f1f..86276f051 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/WorkflowModelFactory.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/WorkflowModelFactory.java @@ -18,6 +18,7 @@ import io.cloudevents.CloudEvent; import io.cloudevents.CloudEventData; import java.time.OffsetDateTime; +import java.util.Base64; import java.util.Map; import java.util.Optional; @@ -41,6 +42,10 @@ public interface WorkflowModelFactory extends ServicePriority map); + default WorkflowModel from(byte[] bytes) { + return from(new String(Base64.getEncoder().encode(bytes))); + } + WorkflowModel fromNull(); default WorkflowModel fromOther(Object obj) { @@ -63,6 +68,7 @@ default WorkflowModel fromAny(Object obj) { case CloudEventData value -> from(value); case OffsetDateTime value -> from(value); case Map map -> from(map); + case byte[] bytes -> from(bytes); case WorkflowModel model -> model; default -> fromOther(obj); }; diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/DigestAuthProvider.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/DigestAuthProvider.java index 1effd3401..b42fff4c7 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/DigestAuthProvider.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/DigestAuthProvider.java @@ -160,7 +160,7 @@ private String processResponse( WorkflowHttpResponse r, URI uri, String method, WorkflowContext workflow, TaskContext task) { int responseCode = r.status(); if (responseCode == 401) { - DigestServerInfo serverInfo = DigestServerInfo.from((String) r.getHeader("WWW-Authenticate")); + DigestServerInfo serverInfo = DigestServerInfo.from(r.getHeader("WWW-Authenticate")); String userName = userFilter.apply(workflow, task); String path = uri.getPath(); String nonceCount; diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java index 84b0299d9..09c8dbc7a 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java @@ -17,16 +17,15 @@ import static org.openworkflow.sdk.impl.WorkflowUtils.buildMapResolver; -import java.net.URI; import org.openworkflow.sdk.api.types.CallHTTP; import org.openworkflow.sdk.api.types.Endpoint; import org.openworkflow.sdk.api.types.HTTPArguments; import org.openworkflow.sdk.api.types.TaskBase; import org.openworkflow.sdk.impl.WorkflowDefinition; import org.openworkflow.sdk.impl.WorkflowMutablePosition; -import org.openworkflow.sdk.impl.WorkflowValueResolver; import org.openworkflow.sdk.impl.executors.CallableTaskBuilder; import org.openworkflow.sdk.impl.executors.CallableTaskFactory; +import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfoBuilderFactory; public class CallableTaskHttpExecutorBuilder implements CallableTaskBuilder { @@ -41,10 +40,6 @@ public CallableTaskFactory init( .withBody(httpArgs.getBody()) .withMethod(httpArgs.getMethod().toUpperCase()) .redirect(httpArgs.isRedirect()); - final Endpoint endpoint = httpArgs.getEndpoint(); - if (endpoint.getEndpointConfiguration() != null) { - builder.withAuth(endpoint.getEndpointConfiguration().getAuthentication()); - } if (httpArgs.getHeaders() != null) { builder.withHeaders( buildMapResolver( @@ -63,9 +58,25 @@ public CallableTaskFactory init( ? httpArgs.getQuery().getHTTPQuery().getAdditionalProperties() : null)); } - final WorkflowValueResolver uriSupplier = - definition.resourceLoader().uriSupplier(endpoint); - return () -> new HttpExecutor(builder, uriSupplier); + final Endpoint endpoint = httpArgs.getEndpoint(); + if (endpoint.getEndpointConfiguration() != null) { + builder.withAuth(endpoint.getEndpointConfiguration().getAuthentication()); + } + return () -> + new HttpExecutor( + builder, + definition.resourceLoader().uriSupplier(endpoint), + buildOutputFunction(httpArgs)); + } + + private WorkflowHttpRequestHelper.WorkflowHttpOutputFunction buildOutputFunction( + HTTPArguments httpArgs) { + return switch (httpArgs.getOutput()) { + case null -> WorkflowHttpRequestHelper.contentFunction; + case CONTENT -> WorkflowHttpRequestHelper.contentFunction; + case RESPONSE -> WorkflowHttpRequestHelper.responseFunction; + case RAW -> WorkflowHttpRequestHelper.rawFunction; + }; } @Override diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java index 398deec94..28254b45a 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java @@ -29,11 +29,15 @@ public class HttpExecutor implements CallableTask { private final WorkflowHttpRequestInfoBuilderFactory builder; private final WorkflowValueResolver uriSupplier; + WorkflowHttpRequestHelper.WorkflowHttpOutputFunction outputFunction; public HttpExecutor( - WorkflowHttpRequestInfoBuilderFactory builder, WorkflowValueResolver uriSupplier) { + WorkflowHttpRequestInfoBuilderFactory builder, + WorkflowValueResolver uriSupplier, + WorkflowHttpRequestHelper.WorkflowHttpOutputFunction outputFunction) { this.builder = builder; this.uriSupplier = uriSupplier; + this.outputFunction = outputFunction; } public CompletableFuture apply( @@ -43,6 +47,7 @@ public CompletableFuture apply( .build(workflow, taskContext, input) .build(uriSupplier.apply(workflow, taskContext, input)), workflow, - taskContext); + taskContext, + outputFunction); } } diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java index 3ed6769f9..fde80df41 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java @@ -83,27 +83,36 @@ private URI addQueryParams(URI uri, Map queryParams) { private BodyPublisher buildBody() { WorkflowModel body = httpInfo.body(); + WorkflowHttpContentType contentType = httpInfo.contentType(); if (body == null || body.isNull()) { return HttpRequest.BodyPublishers.noBody(); } - return switch (httpInfo.contentType()) { - case WorkflowHttpContentType.APPLICATION_JSON -> - HttpRequest.BodyPublishers.ofByteArray( - body.asJson() + if (WorkflowHttpRequestHelper.isTextMediaType(contentType)) { + return HttpRequest.BodyPublishers.ofString( + body.asText() + .orElseThrow(() -> + new IllegalStateException( + "Undelying model needs to support as text to use content type " + + contentType))); + } else if (contentType == WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED) { + return HttpRequest.BodyPublishers.ofString( + encodeMap( + httpInfo + .body() + .asMap() .orElseThrow(() -> new IllegalStateException( - "Underlying model needs to support json in order to use application-json content type"))); - case WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED -> - HttpRequest.BodyPublishers.ofString( - encodeMap( - httpInfo - .body() - .asMap() - .orElseThrow(() -> - new IllegalStateException( - "Underlying model needs to support asMap in order to use form urlencoded content type")), - DefaultHttpRequest::encodeForm)); - }; + "Underlying model needs to support asMap in order to use form urlencoded content type")), + DefaultHttpRequest::encodeForm)); + } else { + return HttpRequest.BodyPublishers.ofByteArray( + httpInfo + .body() + .asJson() + .orElseThrow(() -> + new IllegalStateException( + "Underlying model needs to support json in order to use application-json content type"))); + } } private String encodeMap(Map map, Function decoder) { diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java index d54fd1363..a45385b2f 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java @@ -15,12 +15,16 @@ */ package org.openworkflow.sdk.impl.http; +import java.net.http.HttpClient.Version; import java.net.http.HttpResponse; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.stream.Collectors; import org.openworkflow.sdk.impl.TaskContext; import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowModel; @@ -42,12 +46,12 @@ public DefaultHttpResponse( } @Override - public byte[] asByteArray() { + public byte[] contentAsByteArray() { return response.body(); } @Override - public Object getHeader(String name) { + public String getHeader(String name) { return response.headers().firstValue(name).orElse(null); } @@ -72,12 +76,84 @@ public String errorDetails() { } @Override - public Optional asWorkflowModel() { + public WorkflowModel contentAsModel() { + return responseContentType() + .map(c -> { + if (c.contains("json") || c.contains("yaml")) { + return jsonContent(); + } else if (WorkflowHttpRequestHelper.isTextMediaType(c)) { + return textContent(); + } else if (c.contains("application/octet-stream")) { + return binaryContent(); + } else { + return inferContentType(); + } + }) + .orElse(inferContentType()); + } + + private Optional responseContentType() { + return response.version() == Version.HTTP_2 + ? response.headers().firstValue("content-type") + : response.headers().map().entrySet().stream() + .filter(e -> e.getKey().equalsIgnoreCase("content-type") && !e.getValue().isEmpty()) + .findAny() + .map(e -> e.getValue().stream().map(String::valueOf).collect(Collectors.joining(","))); + } + + private WorkflowModel textContent() { + return application.modelFactory().from(new String(response.body())); + } + + private WorkflowModel inferContentType() { + if (isBinary()) { + return binaryContent(); + } else { + try { + return application.modelFactory().fromJson(response.body()).orElseGet(() -> textContent()); + } catch (Exception ex) { + return textContent(); + } + } + } + + // Sniff according to WHATWG MIME Sniffing rules (yes, too low level ;)) + private boolean isBinary() { + byte[] data = response.body(); + if (data.length >= 2) { + int b0 = data[0] & 0xFF; + int b1 = data[1] & 0xFF; + if ((b0 == 0xFE && b1 == 0xFF) || (b0 == 0xFF && b1 == 0xFE)) { + return false; + } + } + int bytesToScan = Math.min(data.length, 512); + for (int i = 0; i < bytesToScan; i++) { + int b = data[i] & 0xFF; + if ((b <= 0x08) // NUL to BS + || (b == 0x0B) // Vertical Tab + || (b >= 0x0E && b <= 0x1A) // SO to SUB + || (b >= 0x1C && b <= 0x1F) // FS to US + || (b == 0x7F)) { // DEL + return true; + } + } + return false; + } + + private WorkflowModel binaryContent() { + return application.modelFactory().from(response.body()); + } + + private WorkflowModel jsonContent() { + Optional model; try { - return application.modelFactory().fromJson(response.body()); + model = application.modelFactory().fromJson(response.body()); } catch (Exception ex) { throw WorkflowHttpRequestHelper.fromError(ex, context); } + return model.orElseThrow( + () -> new UnsupportedOperationException("Json is not supported by underlying model")); } @Override @@ -96,4 +172,17 @@ private static OptionalLong fromGMTString(String str) { return OptionalLong.empty(); } } + + @Override + public byte[] responseAsByteArray() { + return WorkflowHttpRequestHelper.responseAsByteArray( + this, response.version() == Version.HTTP_2); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Override + public Map> headers() { + Map map = response.headers().map(); + return map; + } } diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpContentType.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpContentType.java index aae95d4f3..7876843bc 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpContentType.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpContentType.java @@ -17,14 +17,25 @@ import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import org.openworkflow.sdk.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding; -public enum WorkflowHttpContentType { - APPLICATION_JSON("application/json"), - APPLICATION_X_WWW_FORM_URLENCODED("application/x-www-form-urlencoded"); +public class WorkflowHttpContentType { + public static final WorkflowHttpContentType APPLICATION_JSON = + new WorkflowHttpContentType("application/json"); + public static final WorkflowHttpContentType APPLICATION_X_WWW_FORM_URLENCODED = + new WorkflowHttpContentType("application/x-www-form-urlencoded"); public static final String HEADER_NAME = "Content-Type"; + private static Map contentTypes = new ConcurrentHashMap<>(); + + static { + contentTypes.put(APPLICATION_JSON.toString(), APPLICATION_JSON); + contentTypes.put( + APPLICATION_X_WWW_FORM_URLENCODED.toString(), APPLICATION_X_WWW_FORM_URLENCODED); + } + private final String value; private WorkflowHttpContentType(String value) { @@ -53,13 +64,8 @@ public static WorkflowHttpContentType fromValue(Object value) { if (indexOf != -1) { candidate = candidate.substring(0, indexOf); } - candidate = candidate.trim(); - for (WorkflowHttpContentType type : WorkflowHttpContentType.values()) { - if (type.value.equalsIgnoreCase(candidate)) { - return type; - } - } - return null; + candidate = candidate.trim().toLowerCase(); + return contentTypes.computeIfAbsent(candidate, c -> new WorkflowHttpContentType(c)); } public String toString() { diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java index afdf36dee..0f003bf46 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java @@ -15,12 +15,20 @@ */ package org.openworkflow.sdk.impl.http; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; import java.net.SocketTimeoutException; import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; import java.util.function.BiConsumer; +import java.util.stream.Collectors; import org.openworkflow.sdk.impl.TaskContext; import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowContext; @@ -34,21 +42,48 @@ public class WorkflowHttpRequestHelper { public static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since"; + private static final String HTTP_NEW_LINE = "\r\n"; + + private static final String CONTENT = "content"; + private static final String HEADERS = "headers"; + private static final String STATUS = "statusCode"; + + public static interface WorkflowHttpOutputFunction { + WorkflowModel apply( + WorkflowHttpResponse response, + WorkflowHttpRequestInfo requestInfo, + WorkflowApplication application, + TaskContext taskContext); + } + + public static final WorkflowHttpOutputFunction contentFunction = + (response, requestInfo, app, taskContext) -> { + validateStatus(taskContext, requestInfo, response); + return response.contentAsModel(); + }; + + public static final WorkflowHttpOutputFunction responseFunction = + (response, requestInfo, app, taskContext) -> responseAsModel(response, app); + + public static final WorkflowHttpOutputFunction rawFunction = + (response, requestInfo, app, taskContext) -> + app.modelFactory().from(response.responseAsByteArray()); public static CompletableFuture executeRequest( WorkflowHttpRequestInfo requestInfo, WorkflowContext workflow, TaskContext taskContext) { - return asWorkflowHtppResponse( - requestInfo, workflow.definition().application(), workflow, taskContext) + return executeRequest(requestInfo, workflow, taskContext, contentFunction); + } + + public static CompletableFuture executeRequest( + WorkflowHttpRequestInfo requestInfo, + WorkflowContext workflow, + TaskContext taskContext, + WorkflowHttpOutputFunction function) { + WorkflowApplication app = workflow.definition().application(); + return asWorkflowHtppResponse(requestInfo, app, workflow, taskContext) .thenApply(response -> { - try { - validateStatus(taskContext, requestInfo, response); - return response - .asWorkflowModel() - .orElseThrow(() -> - new IllegalStateException( - "Unable to extract WorkflowModel from response " + response)); - } finally { - response.close(); + try (response) { + return function.apply(response, requestInfo, app, taskContext); } }); } @@ -62,7 +97,7 @@ public static CompletableFuture executeRequest( .thenApply(response -> { try { validateStatus(taskContext, requestInfo, response); - return response.asByteArray(); + return response.contentAsByteArray(); } finally { response.close(); } @@ -134,6 +169,69 @@ private static CompletableFuture addAuthHeader( }); } + public static byte[] responseAsByteArray(WorkflowHttpResponse response, boolean isHttp2) { + + byte[] content = response.contentAsByteArray(); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + StringBuilder sb = + new StringBuilder( + isHttp2 + ? "HTTP/2 " + response.status() + : "HTTP/1.1 " + response.status() + " " + response.reasonPhrase()); + sb.append(HTTP_NEW_LINE); + for (Map.Entry> entry : response.headers().entrySet()) { + String headerName = entry.getKey(); + for (Object value : entry.getValue()) { + if (headerName.equalsIgnoreCase("Transfer-Encoding") + && value instanceof String str + && str.equalsIgnoreCase("chunked")) { + addHeader(sb, "Content-Length", content.length); + } else { + addHeader(sb, headerName, value); + } + } + } + + sb.append(HTTP_NEW_LINE); + out.write(sb.toString().getBytes(StandardCharsets.ISO_8859_1)); + if (content != null) { + out.write(content); + } + return out.toByteArray(); + } catch (IOException io) { + throw new UncheckedIOException(io); + } + } + + private static void addHeader(StringBuilder sb, String headerName, Object value) { + sb.append(headerName + ": " + value + HTTP_NEW_LINE); + } + + private static WorkflowModel responseAsModel( + WorkflowHttpResponse response, WorkflowApplication application) { + return application + .modelFactory() + .from( + Map.of( + CONTENT, + response.contentAsModel(), + STATUS, + response.status(), + HEADERS, + flatHeaders(response.headers()))); + } + + private static Map flatHeaders(Map> headers) { + return headers.entrySet().stream() + .collect( + Collectors.toMap( + Entry::getKey, + e -> { + List value = e.getValue(); + return value.size() == 1 ? value.get(0) : value; + })); + } + private static boolean isSuccessful(int status) { return inStatusRange(status, 200, 300); } @@ -146,5 +244,16 @@ private static boolean inStatusRange(int status, int lower, int upper) { return status >= lower && status < upper; } + public static boolean isTextMediaType(WorkflowHttpContentType contentType) { + return isTextMediaType(contentType.toString()); + } + + public static boolean isTextMediaType(String contentType) { + return contentType.contains("text/") + || contentType.contains("xml") + || contentType.contains("javascript") + || contentType.contains("html"); + } + private WorkflowHttpRequestHelper() {} } diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpResponse.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpResponse.java index 9a02e162d..3c4e46f70 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpResponse.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpResponse.java @@ -15,17 +15,29 @@ */ package org.openworkflow.sdk.impl.http; -import java.util.Optional; +import java.util.List; +import java.util.Map; import java.util.OptionalLong; import org.openworkflow.sdk.impl.WorkflowModel; public interface WorkflowHttpResponse extends AutoCloseable { - byte[] asByteArray(); + byte[] contentAsByteArray(); - Optional asWorkflowModel(); + byte[] responseAsByteArray(); - Object getHeader(String name); + WorkflowModel contentAsModel(); + + /** Access all headers */ + Map> headers(); + + /** + * Access response header given its name + * + * @param name name of the header + * @return first value of the header as string + */ + String getHeader(String name); int status(); diff --git a/impl/http-jackson/src/main/java/org/openworkflow/sdk/impl/executors/http/jackson/JacksonModelConverterFactory.java b/impl/http-jackson/src/main/java/org/openworkflow/sdk/impl/executors/http/jackson/JacksonModelConverterFactory.java index 84ca68b3f..be0b6b959 100644 --- a/impl/http-jackson/src/main/java/org/openworkflow/sdk/impl/executors/http/jackson/JacksonModelConverterFactory.java +++ b/impl/http-jackson/src/main/java/org/openworkflow/sdk/impl/executors/http/jackson/JacksonModelConverterFactory.java @@ -16,6 +16,7 @@ package org.openworkflow.sdk.impl.executors.http.jackson; import org.openworkflow.sdk.impl.additional.NamedSuppliedAdditionalObject; +import org.openworkflow.sdk.impl.jakarta.http.DefaultHttpModelConverter; import org.openworkflow.sdk.impl.jakarta.http.HttpConverterResolver; import org.openworkflow.sdk.impl.jakarta.http.HttpModelConverter; import tools.jackson.databind.JsonNode; @@ -26,9 +27,9 @@ public class JacksonModelConverterFactory private static class JacksonModelConverterHolder { private static HttpModelConverter converter = - new HttpModelConverter() { + new DefaultHttpModelConverter() { @Override - public Class responseType() { + public Class jsonResponseType() { return JsonNode.class; } }; diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/DefaultHttpModelConverter.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/DefaultHttpModelConverter.java new file mode 100644 index 000000000..c22760076 --- /dev/null +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/DefaultHttpModelConverter.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openworkflow.sdk.impl.jakarta.http; + +import jakarta.ws.rs.core.MediaType; + +public class DefaultHttpModelConverter implements HttpModelConverter { + + @Override + public Class responseType(MediaType mediaType) { + if (MediaType.APPLICATION_JSON_TYPE.isCompatible(mediaType)) { + return jsonResponseType(); + } else if (MediaType.APPLICATION_OCTET_STREAM_TYPE.isCompatible(mediaType)) { + return byte[].class; + } else { + return String.class; + } + } + + protected Class jsonResponseType() { + return Object.class; + } +} diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpConverterResolver.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpConverterResolver.java index 8b2673d39..450e20fac 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpConverterResolver.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpConverterResolver.java @@ -24,13 +24,7 @@ public class HttpConverterResolver { public static final String HTTP_MODEL_CONVERTER = "httpModelConverter"; private static class DefaultHolder { - private static final HttpModelConverter converter = - new HttpModelConverter() { - @Override - public Class responseType() { - return Object.class; - } - }; + private static final HttpModelConverter converter = new DefaultHttpModelConverter(); } public static HttpModelConverter converter( diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpModelConverter.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpModelConverter.java index 25b751e6e..2de8c4be3 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpModelConverter.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/HttpModelConverter.java @@ -15,14 +15,8 @@ */ package org.openworkflow.sdk.impl.jakarta.http; -import jakarta.ws.rs.client.Entity; -import org.openworkflow.sdk.impl.WorkflowModel; +import jakarta.ws.rs.core.MediaType; public interface HttpModelConverter { - - default Entity toEntity(WorkflowModel model) { - return Entity.json(model.as(model.objectClass()).orElseThrow()); - } - - Class responseType(); + Class responseType(MediaType mediaType); } diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java index 07572779a..4a7365c94 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java @@ -17,11 +17,12 @@ import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collection; import java.util.Date; import java.util.List; -import java.util.Optional; +import java.util.Map; import java.util.OptionalLong; -import java.util.stream.Collectors; import org.openworkflow.sdk.impl.TaskContext; import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowModel; @@ -47,21 +48,10 @@ public JakartaHttpResponse( } @Override - public byte[] asByteArray() { + public byte[] contentAsByteArray() { return response.readEntity(byte[].class); } - @Override - public Object getHeader(String name) { - return fromListToSingleObject(response.getHeaders().get(name)); - } - - private Object fromListToSingleObject(List list) { - return list == null - ? null - : list.stream().map(Object::toString).collect(Collectors.joining(",")); - } - @Override public int status() { return response.getStatus(); @@ -93,9 +83,10 @@ public String errorDetails() { } @Override - public Optional asWorkflowModel() { + public WorkflowModel contentAsModel() { try { - return Optional.of(app.modelFactory().fromAny(response.readEntity(converter.responseType()))); + return app.modelFactory() + .fromAny(response.readEntity(converter.responseType(response.getMediaType()))); } catch (ProcessingException ex) { throw WorkflowHttpRequestHelper.fromError(ex, taskContext); } @@ -105,4 +96,40 @@ public Optional asWorkflowModel() { public String toString() { return "JakartaHttpResponse [response=" + response + "]"; } + + @Override + public byte[] responseAsByteArray() { + return WorkflowHttpRequestHelper.responseAsByteArray(this, isHttp2()); + } + + @Override + public Map> headers() { + return response.getHeaders(); + } + + @Override + public String getHeader(String name) { + return response.getHeaderString(name); + } + + private static final Collection FORBIDDEN_HTTP2_HEADERS = + Arrays.asList("Connection", "Keep-Alive", "Proxy-Connection", "Transfer-Encoding", "Upgrade"); + + private boolean isHttp2() { + for (String str : response.getHeaders().keySet()) { + if (containsUpperCase(str) || FORBIDDEN_HTTP2_HEADERS.contains(str)) { + return false; + } + } + return true; + } + + private boolean containsUpperCase(String str) { + for (int i = 0; i < str.length(); i++) { + if (Character.isUpperCase(str.charAt(i))) { + return true; + } + } + return false; + } } diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java index 08ee26796..83bf0c972 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java @@ -21,7 +21,6 @@ import jakarta.ws.rs.client.Invocation.Builder; import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Form; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.Collection; import java.util.List; @@ -35,6 +34,8 @@ import org.openworkflow.sdk.impl.WorkflowContext; import org.openworkflow.sdk.impl.WorkflowError; import org.openworkflow.sdk.impl.WorkflowException; +import org.openworkflow.sdk.impl.WorkflowModel; +import org.openworkflow.sdk.impl.http.WorkflowHttpContentType; import org.openworkflow.sdk.impl.http.WorkflowHttpRequest; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfo; @@ -101,23 +102,27 @@ public CompletableFuture invoke( WorkflowHttpRequestHelper.fromError(ex, taskContext))); } - private Entity buildBody(HttpModelConverter converter) { - return switch (requestInfo.contentType()) { - case APPLICATION_JSON -> converter.toEntity(requestInfo.body()); - case APPLICATION_X_WWW_FORM_URLENCODED -> buildForm(); - }; - } - - private Entity
buildForm() { - Form form = new Form(); - requestInfo - .body() - .asMap() - .orElseThrow(() -> - new IllegalStateException( - "Workflow model should support asMap in order form_url_enconded to work")) - .forEach((k, v) -> form.param(k, v == null ? "" : v.toString())); - return Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED); + private Entity buildBody() { + WorkflowHttpContentType contentType = requestInfo.contentType(); + WorkflowModel model = requestInfo.body(); + if (contentType == WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED) { + Form form = new Form(); + requestInfo + .body() + .asMap() + .orElseThrow(() -> + new IllegalStateException( + "Workflow model should support asMap in order form_url_enconded to work")) + .forEach((k, v) -> form.param(k, v == null ? "" : v.toString())); + return Entity.form(form); + } else if (WorkflowHttpRequestHelper.isTextMediaType(contentType)) { + return Entity.text( + model + .asText() + .orElse("Workflow model should support asText for content type " + contentType)); + } else { + return Entity.json(model.as(model.objectClass()).orElseThrow()); + } } private CompletableFuture doRequest( @@ -125,7 +130,7 @@ private CompletableFuture doRequest( CompletionStage future = requestInfo.body() == null || requestInfo.body().isNull() ? request.rx().method(requestInfo.method()) - : request.rx().method(requestInfo.method(), buildBody(converter)); + : request.rx().method(requestInfo.method(), buildBody()); return future .toCompletableFuture() .thenApply(r -> new JakartaHttpResponse(application, task, converter, r)); diff --git a/impl/model/src/main/java/org/openworkflow/sdk/impl/model/jackson/JacksonModelFactory.java b/impl/model/src/main/java/org/openworkflow/sdk/impl/model/jackson/JacksonModelFactory.java index 5d37c9ad9..e7c1df7f7 100644 --- a/impl/model/src/main/java/org/openworkflow/sdk/impl/model/jackson/JacksonModelFactory.java +++ b/impl/model/src/main/java/org/openworkflow/sdk/impl/model/jackson/JacksonModelFactory.java @@ -110,6 +110,11 @@ public WorkflowModel fromOther(Object value) { return new JacksonModel(JsonUtils.fromValue(value)); } + @Override + public WorkflowModel from(byte[] bytes) { + return new JacksonModel(JsonUtils.fromValue(bytes)); + } + @Override public Optional fromJson(byte[] value) { return value == null || value.length == 0 diff --git a/impl/test/src/test/java/org/openworkflow/sdk/impl/test/HTTPWorkflowDefinitionTest.java b/impl/test/src/test/java/org/openworkflow/sdk/impl/test/HTTPWorkflowDefinitionTest.java index df92a96ed..47bd0c0b0 100644 --- a/impl/test/src/test/java/org/openworkflow/sdk/impl/test/HTTPWorkflowDefinitionTest.java +++ b/impl/test/src/test/java/org/openworkflow/sdk/impl/test/HTTPWorkflowDefinitionTest.java @@ -140,6 +140,103 @@ void callHttpGet_should_return_pet_data(WorkflowApplication app) throws Exceptio }); } + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("applications") + void callHttpGet_should_return_pet_data_no_content(WorkflowApplication app) throws Exception { + mockServer.enqueue( + new MockResponse( + 200, + Headers.of(), + """ + { + "petId": 10, + "photoUrls": [ + "https://example.com/photos/rex1.jpg", + "https://example.com/photos/rex2.jpg" + ] + } + """)); + WorkflowModel result = + app.workflowDefinition(readWorkflowFromClasspath("workflows-samples/call-http-get.yaml")) + .instance(Map.of("petId", 10)) + .start() + .join(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result.asMap().orElseThrow()).containsKey("petId").containsKey("photoUrls"); + try { + RecordedRequest recordedRequest = mockServer.takeRequest(); + softly.assertThat(recordedRequest.getUrl()).asString().contains("/pets/10"); + softly.assertThat(recordedRequest.getMethod()).isEqualTo("GET"); + } catch (InterruptedException e) { + softly.fail(e); + } + }); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("applications") + void callHttpGet_text_plain(WorkflowApplication app) throws Exception { + final String theText = "VIVA ER BETIS!!!"; + mockServer.enqueue(new MockResponse(200, Headers.of("Content-Type", "text/plain"), theText)); + WorkflowModel result = + app.workflowDefinition(readWorkflowFromClasspath("workflows-samples/call-http-get.yaml")) + .instance(Map.of("petId", 10)) + .start() + .join(); + assertThat(result.asText().orElseThrow()).isEqualTo(theText); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("applications") + void callHttpGet_text_noContent(WorkflowApplication app) throws Exception { + final String theText = "VIVA ER BETIS!!!"; + mockServer.enqueue(new MockResponse(200, Headers.of(), theText)); + WorkflowModel result = + app.workflowDefinition(readWorkflowFromClasspath("workflows-samples/call-http-get.yaml")) + .instance(Map.of("petId", 10)) + .start() + .join(); + assertThat(result.asText().orElseThrow()).isEqualTo(theText); + } + + record FullResponse(Map headers, Map content, int statusCode) {} + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("applications") + void callHttpGet_should_return_pet_data_and_headers(WorkflowApplication app) throws Exception { + mockServer.enqueue( + new MockResponse( + 200, + Headers.of("Content-Type", "application/json", "extra", "javierito"), + """ + { + "petId": 10, + "photoUrls": [ + "https://example.com/photos/rex1.jpg", + "https://example.com/photos/rex2.jpg" + ] + } + """)); + WorkflowModel result = + app.workflowDefinition( + readWorkflowFromClasspath("workflows-samples/call-http-get-headers.yaml")) + .instance(Map.of("petId", 10)) + .start() + .join(); + SoftAssertions.assertSoftly(softly -> { + FullResponse response = result.as(FullResponse.class).orElseThrow(); + softly.assertThat(response.headers()).containsEntry("extra", "javierito"); + softly.assertThat(response.content()).containsKey("petId").containsKey("photoUrls"); + try { + RecordedRequest recordedRequest = mockServer.takeRequest(); + softly.assertThat(recordedRequest.getUrl()).asString().contains("/pets/10"); + softly.assertThat(recordedRequest.getMethod()).isEqualTo("GET"); + } catch (InterruptedException e) { + softly.fail(e); + } + }); + } + @ParameterizedTest(autoCloseArguments = false) @MethodSource("applications") void callHttpGet_with_not_found_petId_should_keep_input_petId(WorkflowApplication app) diff --git a/impl/test/src/test/resources/workflows-samples/call-http-get-headers.yaml b/impl/test/src/test/resources/workflows-samples/call-http-get-headers.yaml new file mode 100644 index 000000000..2b9f73a66 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/call-http-get-headers.yaml @@ -0,0 +1,22 @@ +document: + dsl: 1.0.0-alpha1 + namespace: test + name: call-http-with-response-headers + version: 1.0.0 +do: + - tryGetPet: + try: + - getPet: + call: http + with: + headers: + content-type: application/json + method: get + output: response + endpoint: + uri: http://localhost:9876/pets/{petId} + catch: + errors: + with: + type: https://open-workflow-specification.org/spec/1.0.0/errors/communication + status: 404 \ No newline at end of file From c3cb2f7218a2b0ebe8124e46516dbe6c5215e861 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti Date: Fri, 13 Nov 2026 20:21:30 +0100 Subject: [PATCH 2/2] [Fix #1829] Change WorkflowHttpRequestHelper name Signed-off-by: Francisco Javier Tirado Sarti --- .../sdk/impl/auth/AccessTokenProvider.java | 4 +- .../http/CallableTaskHttpExecutorBuilder.java | 12 ++-- .../sdk/impl/executors/http/HttpExecutor.java | 8 +-- .../executors/openapi/OpenAPIExecutor.java | 6 +- .../sdk/impl/http/DefaultHttpRequest.java | 61 ++++++++++++------- .../sdk/impl/http/DefaultHttpResponse.java | 7 +-- ...estHelper.java => WorkflowHttpHelper.java} | 7 +-- .../sdk/impl/resources/HttpResource.java | 6 +- .../jakarta/http/JakartaHttpResponse.java | 6 +- .../jakarta/http/JakartaRequestExecutor.java | 44 ++++++++----- 10 files changed, 96 insertions(+), 65 deletions(-) rename impl/core/src/main/java/org/openworkflow/sdk/impl/http/{WorkflowHttpRequestHelper.java => WorkflowHttpHelper.java} (99%) diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/AccessTokenProvider.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/AccessTokenProvider.java index 2d0969070..c4540dc82 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/AccessTokenProvider.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/auth/AccessTokenProvider.java @@ -25,7 +25,7 @@ import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowContext; import org.openworkflow.sdk.impl.http.WorkflowHttpContentType; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfo; class AccessTokenProvider { @@ -48,7 +48,7 @@ public CompletableFuture validateAndGet(WorkflowContext workflow, TaskConte WorkflowHttpRequestInfo httpRequestInfo = buildHttpRequestInfo(workflow, context, authRequestInfo); CompletableFuture future = - WorkflowHttpRequestHelper.executeRequest(httpRequestInfo, app, workflow, context) + WorkflowHttpHelper.executeRequest(httpRequestInfo, app, workflow, context) .thenApply(bytes -> jwtConverter.fromToken(bytes)); List issuers = authRequestInfo.issuers(); if (!issuers.isEmpty()) { diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java index 09c8dbc7a..daa321385 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/CallableTaskHttpExecutorBuilder.java @@ -25,7 +25,7 @@ import org.openworkflow.sdk.impl.WorkflowMutablePosition; import org.openworkflow.sdk.impl.executors.CallableTaskBuilder; import org.openworkflow.sdk.impl.executors.CallableTaskFactory; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfoBuilderFactory; public class CallableTaskHttpExecutorBuilder implements CallableTaskBuilder { @@ -69,13 +69,13 @@ public CallableTaskFactory init( buildOutputFunction(httpArgs)); } - private WorkflowHttpRequestHelper.WorkflowHttpOutputFunction buildOutputFunction( + private WorkflowHttpHelper.WorkflowHttpOutputFunction buildOutputFunction( HTTPArguments httpArgs) { return switch (httpArgs.getOutput()) { - case null -> WorkflowHttpRequestHelper.contentFunction; - case CONTENT -> WorkflowHttpRequestHelper.contentFunction; - case RESPONSE -> WorkflowHttpRequestHelper.responseFunction; - case RAW -> WorkflowHttpRequestHelper.rawFunction; + case null -> WorkflowHttpHelper.contentFunction; + case CONTENT -> WorkflowHttpHelper.contentFunction; + case RESPONSE -> WorkflowHttpHelper.responseFunction; + case RAW -> WorkflowHttpHelper.rawFunction; }; } diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java index 28254b45a..bfe41c40e 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/http/HttpExecutor.java @@ -22,19 +22,19 @@ import org.openworkflow.sdk.impl.WorkflowModel; import org.openworkflow.sdk.impl.WorkflowValueResolver; import org.openworkflow.sdk.impl.executors.CallableTask; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfoBuilderFactory; public class HttpExecutor implements CallableTask { private final WorkflowHttpRequestInfoBuilderFactory builder; private final WorkflowValueResolver uriSupplier; - WorkflowHttpRequestHelper.WorkflowHttpOutputFunction outputFunction; + WorkflowHttpHelper.WorkflowHttpOutputFunction outputFunction; public HttpExecutor( WorkflowHttpRequestInfoBuilderFactory builder, WorkflowValueResolver uriSupplier, - WorkflowHttpRequestHelper.WorkflowHttpOutputFunction outputFunction) { + WorkflowHttpHelper.WorkflowHttpOutputFunction outputFunction) { this.builder = builder; this.uriSupplier = uriSupplier; this.outputFunction = outputFunction; @@ -42,7 +42,7 @@ public HttpExecutor( public CompletableFuture apply( WorkflowContext workflow, TaskContext taskContext, WorkflowModel input) { - return WorkflowHttpRequestHelper.executeRequest( + return WorkflowHttpHelper.executeRequest( builder .build(workflow, taskContext, input) .build(uriSupplier.apply(workflow, taskContext, input)), diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/openapi/OpenAPIExecutor.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/openapi/OpenAPIExecutor.java index ace70db0e..cee219771 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/openapi/OpenAPIExecutor.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/executors/openapi/OpenAPIExecutor.java @@ -28,7 +28,7 @@ import org.openworkflow.sdk.impl.WorkflowContext; import org.openworkflow.sdk.impl.WorkflowModel; import org.openworkflow.sdk.impl.executors.CallableTask; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfo.Builder; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfoBuilderFactory; @@ -72,13 +72,13 @@ public CompletableFuture apply( "List of servers is empty for schema " + resource.getName()); } CompletableFuture future = - WorkflowHttpRequestHelper.executeRequest( + WorkflowHttpHelper.executeRequest( httpInfoBuilder.build(URI.create(iter.next())), workflowContext, taskContext); while (iter.hasNext()) { final String uri = iter.next(); future = future.exceptionallyCompose(i -> - WorkflowHttpRequestHelper.executeRequest( + WorkflowHttpHelper.executeRequest( httpInfoBuilder.build(URI.create(uri)), workflowContext, taskContext)); } return future; diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java index fde80df41..df664a905 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpRequest.java @@ -34,9 +34,9 @@ public class DefaultHttpRequest implements WorkflowHttpRequest { - private final WorkflowHttpRequestInfo httpInfo; + protected final WorkflowHttpRequestInfo httpInfo; + protected final WorkflowApplication app; private final HttpClient client; - private final WorkflowApplication app; public DefaultHttpRequest( WorkflowApplication app, HttpClient client, WorkflowHttpRequestInfo httpInfo) { @@ -62,14 +62,13 @@ public CompletableFuture invoke( builder.uri(uri); String ifModifiedSince = httpInfo.ifModifiedSince(); if (ifModifiedSince != null) { - builder.header(WorkflowHttpRequestHelper.IF_MODIFIED_SINCE_HEADER, ifModifiedSince); + builder.header(WorkflowHttpHelper.IF_MODIFIED_SINCE_HEADER, ifModifiedSince); } - return WorkflowHttpRequestHelper.processAuth( - httpInfo, builder::header, workflowContext, taskContext) + return WorkflowHttpHelper.processAuth(httpInfo, builder::header, workflowContext, taskContext) .thenCompose( __ -> client.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofByteArray())) - .exceptionallyCompose(ex -> - CompletableFuture.failedFuture(WorkflowHttpRequestHelper.fromError(ex, taskContext))) + .exceptionallyCompose( + ex -> CompletableFuture.failedFuture(WorkflowHttpHelper.fromError(ex, taskContext))) .thenApply(response -> new DefaultHttpResponse(app, taskContext, response)); } @@ -81,19 +80,14 @@ private URI addQueryParams(URI uri, Map queryParams) { .build(); } - private BodyPublisher buildBody() { + protected BodyPublisher buildBody() { WorkflowModel body = httpInfo.body(); WorkflowHttpContentType contentType = httpInfo.contentType(); if (body == null || body.isNull()) { return HttpRequest.BodyPublishers.noBody(); } - if (WorkflowHttpRequestHelper.isTextMediaType(contentType)) { - return HttpRequest.BodyPublishers.ofString( - body.asText() - .orElseThrow(() -> - new IllegalStateException( - "Undelying model needs to support as text to use content type " - + contentType))); + if (contentType == WorkflowHttpContentType.APPLICATION_JSON) { + return buildJsonBody(); } else if (contentType == WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED) { return HttpRequest.BodyPublishers.ofString( encodeMap( @@ -104,17 +98,42 @@ private BodyPublisher buildBody() { new IllegalStateException( "Underlying model needs to support asMap in order to use form urlencoded content type")), DefaultHttpRequest::encodeForm)); - } else { - return HttpRequest.BodyPublishers.ofByteArray( - httpInfo - .body() - .asJson() + } else if (isTextMediaType(contentType)) { + return HttpRequest.BodyPublishers.ofString( + body.asText() .orElseThrow(() -> new IllegalStateException( - "Underlying model needs to support json in order to use application-json content type"))); + "Undelying model needs to support as text to use content type " + + contentType))); + } else { + return fallbackBuildBody(); } } + /* + * User might override this to change which is considered a text media type + */ + protected boolean isTextMediaType(WorkflowHttpContentType contentType) { + return WorkflowHttpHelper.isTextMediaType(contentType); + } + + /* + * Users might override this to provide different behavior for additional content types + */ + protected BodyPublisher fallbackBuildBody() { + return buildJsonBody(); + } + + private BodyPublisher buildJsonBody() { + return HttpRequest.BodyPublishers.ofByteArray( + httpInfo + .body() + .asJson() + .orElseThrow(() -> + new IllegalStateException( + "Underlying model needs to support json in order to use application-json content type"))); + } + private String encodeMap(Map map, Function decoder) { return map.entrySet().stream() .map(entry -> decoder.apply(entry.getKey()) + "=" + decoder.apply(entry.getValue())) diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java index a45385b2f..946aef09f 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/DefaultHttpResponse.java @@ -81,7 +81,7 @@ public WorkflowModel contentAsModel() { .map(c -> { if (c.contains("json") || c.contains("yaml")) { return jsonContent(); - } else if (WorkflowHttpRequestHelper.isTextMediaType(c)) { + } else if (WorkflowHttpHelper.isTextMediaType(c)) { return textContent(); } else if (c.contains("application/octet-stream")) { return binaryContent(); @@ -150,7 +150,7 @@ private WorkflowModel jsonContent() { try { model = application.modelFactory().fromJson(response.body()); } catch (Exception ex) { - throw WorkflowHttpRequestHelper.fromError(ex, context); + throw WorkflowHttpHelper.fromError(ex, context); } return model.orElseThrow( () -> new UnsupportedOperationException("Json is not supported by underlying model")); @@ -175,8 +175,7 @@ private static OptionalLong fromGMTString(String str) { @Override public byte[] responseAsByteArray() { - return WorkflowHttpRequestHelper.responseAsByteArray( - this, response.version() == Version.HTTP_2); + return WorkflowHttpHelper.responseAsByteArray(this, response.version() == Version.HTTP_2); } @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpHelper.java similarity index 99% rename from impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java rename to impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpHelper.java index 0f003bf46..137542c98 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpRequestHelper.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/http/WorkflowHttpHelper.java @@ -39,7 +39,7 @@ import org.openworkflow.sdk.impl.auth.AuthUtils; import org.openworkflow.sdk.types.Errors; -public class WorkflowHttpRequestHelper { +public class WorkflowHttpHelper { public static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since"; private static final String HTTP_NEW_LINE = "\r\n"; @@ -170,7 +170,6 @@ private static CompletableFuture addAuthHeader( } public static byte[] responseAsByteArray(WorkflowHttpResponse response, boolean isHttp2) { - byte[] content = response.contentAsByteArray(); try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { StringBuilder sb = @@ -191,9 +190,9 @@ public static byte[] responseAsByteArray(WorkflowHttpResponse response, boolean } } } - sb.append(HTTP_NEW_LINE); out.write(sb.toString().getBytes(StandardCharsets.ISO_8859_1)); + if (content != null) { out.write(content); } @@ -255,5 +254,5 @@ public static boolean isTextMediaType(String contentType) { || contentType.contains("html"); } - private WorkflowHttpRequestHelper() {} + private WorkflowHttpHelper() {} } diff --git a/impl/core/src/main/java/org/openworkflow/sdk/impl/resources/HttpResource.java b/impl/core/src/main/java/org/openworkflow/sdk/impl/resources/HttpResource.java index d6e28edee..0a266d9c2 100644 --- a/impl/core/src/main/java/org/openworkflow/sdk/impl/resources/HttpResource.java +++ b/impl/core/src/main/java/org/openworkflow/sdk/impl/resources/HttpResource.java @@ -26,7 +26,7 @@ import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowContext; import org.openworkflow.sdk.impl.auth.AuthProvider; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfo; public class HttpResource implements ExternalResourceHandler { @@ -44,7 +44,7 @@ public HttpResource(WorkflowApplication application, URI url, AuthProvider auth) @Override public CompletableFuture load(WorkflowContext workflowContext, TaskContext taskContext) { - return WorkflowHttpRequestHelper.executeRequest( + return WorkflowHttpHelper.executeRequest( WorkflowHttpRequestInfo.builder("GET").auth(auth).build(url), application, workflowContext, @@ -66,7 +66,7 @@ public String name() { public CompletableFuture shouldReload( Instant lastUpdate, WorkflowContext workflowContext, TaskContext taskContext) { final long millis = lastUpdate.toEpochMilli(); - return WorkflowHttpRequestHelper.asWorkflowHtppResponse( + return WorkflowHttpHelper.asWorkflowHtppResponse( WorkflowHttpRequestInfo.builder("HEAD").ifModifiedSince(millis).auth(auth).build(url), application, workflowContext, diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java index 4a7365c94..1744c006a 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaHttpResponse.java @@ -26,7 +26,7 @@ import org.openworkflow.sdk.impl.TaskContext; import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowModel; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpResponse; public class JakartaHttpResponse implements WorkflowHttpResponse { @@ -88,7 +88,7 @@ public WorkflowModel contentAsModel() { return app.modelFactory() .fromAny(response.readEntity(converter.responseType(response.getMediaType()))); } catch (ProcessingException ex) { - throw WorkflowHttpRequestHelper.fromError(ex, taskContext); + throw WorkflowHttpHelper.fromError(ex, taskContext); } } @@ -99,7 +99,7 @@ public String toString() { @Override public byte[] responseAsByteArray() { - return WorkflowHttpRequestHelper.responseAsByteArray(this, isHttp2()); + return WorkflowHttpHelper.responseAsByteArray(this, isHttp2()); } @Override diff --git a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java index 83bf0c972..768135d82 100644 --- a/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java +++ b/impl/http/src/main/java/org/openworkflow/sdk/impl/jakarta/http/JakartaRequestExecutor.java @@ -28,7 +28,6 @@ import java.util.Map.Entry; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; -import java.util.concurrent.ExecutorService; import org.openworkflow.sdk.impl.TaskContext; import org.openworkflow.sdk.impl.WorkflowApplication; import org.openworkflow.sdk.impl.WorkflowContext; @@ -36,16 +35,15 @@ import org.openworkflow.sdk.impl.WorkflowException; import org.openworkflow.sdk.impl.WorkflowModel; import org.openworkflow.sdk.impl.http.WorkflowHttpContentType; +import org.openworkflow.sdk.impl.http.WorkflowHttpHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequest; -import org.openworkflow.sdk.impl.http.WorkflowHttpRequestHelper; import org.openworkflow.sdk.impl.http.WorkflowHttpRequestInfo; import org.openworkflow.sdk.impl.http.WorkflowHttpResponse; -class JakartaRequestExecutor implements WorkflowHttpRequest { +public class JakartaRequestExecutor implements WorkflowHttpRequest { - private final ExecutorService executorService; - private final WorkflowHttpRequestInfo requestInfo; - private final WorkflowApplication application; + protected final WorkflowHttpRequestInfo requestInfo; + protected final WorkflowApplication application; private final Client defaultClient; private final Collection requestDecorators; @@ -56,7 +54,6 @@ public JakartaRequestExecutor( List requestDecorators) { this.application = application; this.defaultClient = defaultClient; - this.executorService = application.executorService(); this.requestInfo = requestInfo; this.requestDecorators = requestDecorators; } @@ -77,13 +74,13 @@ public CompletableFuture invoke( Builder request = target.request(); String ifModifiedSince = requestInfo.ifModifiedSince(); if (ifModifiedSince != null) { - request.header(WorkflowHttpRequestHelper.IF_MODIFIED_SINCE_HEADER, ifModifiedSince); + request.header(WorkflowHttpHelper.IF_MODIFIED_SINCE_HEADER, ifModifiedSince); } requestInfo.headers().forEach(request::header); if (workflowContext != null) { requestDecorators.forEach(d -> d.decorate(request, workflowContext, taskContext)); } - return WorkflowHttpRequestHelper.processAuth( + return WorkflowHttpHelper.processAuth( requestInfo, request::header, workflowContext, taskContext) .thenCompose(__ -> doRequest( @@ -98,14 +95,15 @@ public CompletableFuture invoke( webAppEx.getResponse().getStatus(), taskContext, webAppEx) .build(), webAppEx)) - : CompletableFuture.failedFuture( - WorkflowHttpRequestHelper.fromError(ex, taskContext))); + : CompletableFuture.failedFuture(WorkflowHttpHelper.fromError(ex, taskContext))); } - private Entity buildBody() { + protected Entity buildBody() { WorkflowHttpContentType contentType = requestInfo.contentType(); WorkflowModel model = requestInfo.body(); - if (contentType == WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED) { + if (contentType == WorkflowHttpContentType.APPLICATION_JSON) { + return Entity.json(model.as(model.objectClass()).orElseThrow()); + } else if (contentType == WorkflowHttpContentType.APPLICATION_X_WWW_FORM_URLENCODED) { Form form = new Form(); requestInfo .body() @@ -115,16 +113,32 @@ private Entity buildBody() { "Workflow model should support asMap in order form_url_enconded to work")) .forEach((k, v) -> form.param(k, v == null ? "" : v.toString())); return Entity.form(form); - } else if (WorkflowHttpRequestHelper.isTextMediaType(contentType)) { + } else if (isTextMediaType(contentType)) { return Entity.text( model .asText() .orElse("Workflow model should support asText for content type " + contentType)); } else { - return Entity.json(model.as(model.objectClass()).orElseThrow()); + return fallbackBuildBody(); } } + /* Users might override to determine which content types are text + * @return boolean indicating if the media type is a text one*/ + protected boolean isTextMediaType(WorkflowHttpContentType contentType) { + return WorkflowHttpHelper.isTextMediaType(contentType); + } + + /* + * Child classes might inherit this method to change default behavior for not media text types + * (or if they do not like default behavior for other types, they might override buildBody) + * @return the Entity to be sent as body + */ + protected Entity fallbackBuildBody() { + WorkflowModel model = requestInfo.body(); + return Entity.json(model.as(model.objectClass()).orElseThrow()); + } + private CompletableFuture doRequest( Builder request, HttpModelConverter converter, TaskContext task) { CompletionStage future =