diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java index c89ad019..3dbe9509 100644 --- a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java @@ -24,6 +24,7 @@ import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.listen; import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.toOne; import static io.serverlessworkflow.fluent.spec.dsl.DSL.auth; +import static io.serverlessworkflow.fluent.spec.dsl.DSL.use; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -263,7 +264,7 @@ void get_convenience_creates_http_get() { void get_named_with_authentication_uses_auth_policy() { Workflow wf = FuncWorkflowBuilder.workflow("http-get-auth") - .tasks(get("fetchUsers", "http://service/api/users", auth("user-service-auth"))) + .tasks(get("fetchUsers", "http://service/api/users", use("user-service-auth"))) .build(); List items = wf.getDo(); diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java index 9c304023..3136ce6c 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/ReferenceableAuthenticationPolicyBuilder.java @@ -21,51 +21,44 @@ import java.util.function.Consumer; public class ReferenceableAuthenticationPolicyBuilder { - final AuthenticationPolicyUnion authenticationPolicy; - final AuthenticationPolicyReference authenticationPolicyReference; + private AuthenticationPolicyUnion authenticationPolicy; + private AuthenticationPolicyReference authenticationPolicyReference; - public ReferenceableAuthenticationPolicyBuilder() { - this.authenticationPolicy = new AuthenticationPolicyUnion(); - this.authenticationPolicyReference = new AuthenticationPolicyReference(); - } + public ReferenceableAuthenticationPolicyBuilder() {} public ReferenceableAuthenticationPolicyBuilder basic( Consumer basicConsumer) { - final BasicAuthenticationPolicyBuilder basicAuthenticationPolicyBuilder = - new BasicAuthenticationPolicyBuilder(); - basicConsumer.accept(basicAuthenticationPolicyBuilder); - this.authenticationPolicy.setBasicAuthenticationPolicy( - basicAuthenticationPolicyBuilder.build()); + final BasicAuthenticationPolicyBuilder builder = new BasicAuthenticationPolicyBuilder(); + basicConsumer.accept(builder); + this.authenticationPolicy = + new AuthenticationPolicyUnion().withBasicAuthenticationPolicy(builder.build()); return this; } public ReferenceableAuthenticationPolicyBuilder bearer( Consumer bearerConsumer) { - final BearerAuthenticationPolicyBuilder bearerAuthenticationPolicyBuilder = - new BearerAuthenticationPolicyBuilder(); - bearerConsumer.accept(bearerAuthenticationPolicyBuilder); - this.authenticationPolicy.setBearerAuthenticationPolicy( - bearerAuthenticationPolicyBuilder.build()); + final BearerAuthenticationPolicyBuilder builder = new BearerAuthenticationPolicyBuilder(); + bearerConsumer.accept(builder); + this.authenticationPolicy = + new AuthenticationPolicyUnion().withBearerAuthenticationPolicy(builder.build()); return this; } public ReferenceableAuthenticationPolicyBuilder digest( Consumer digestConsumer) { - final DigestAuthenticationPolicyBuilder digestAuthenticationPolicyBuilder = - new DigestAuthenticationPolicyBuilder(); - digestConsumer.accept(digestAuthenticationPolicyBuilder); - this.authenticationPolicy.setDigestAuthenticationPolicy( - digestAuthenticationPolicyBuilder.build()); + final DigestAuthenticationPolicyBuilder builder = new DigestAuthenticationPolicyBuilder(); + digestConsumer.accept(builder); + this.authenticationPolicy = + new AuthenticationPolicyUnion().withDigestAuthenticationPolicy(builder.build()); return this; } public ReferenceableAuthenticationPolicyBuilder oauth2( Consumer oauth2Consumer) { - final OAuth2AuthenticationPolicyBuilder oauth2AuthenticationPolicyBuilder = - new OAuth2AuthenticationPolicyBuilder(); - oauth2Consumer.accept(oauth2AuthenticationPolicyBuilder); - this.authenticationPolicy.setOAuth2AuthenticationPolicy( - oauth2AuthenticationPolicyBuilder.build()); + final OAuth2AuthenticationPolicyBuilder builder = new OAuth2AuthenticationPolicyBuilder(); + oauth2Consumer.accept(builder); + this.authenticationPolicy = + new AuthenticationPolicyUnion().withOAuth2AuthenticationPolicy(builder.build()); return this; } @@ -74,12 +67,13 @@ public ReferenceableAuthenticationPolicyBuilder openIDConnect( final OpenIdConnectAuthenticationPolicyBuilder builder = new OpenIdConnectAuthenticationPolicyBuilder(); openIdConnectConsumer.accept(builder); - this.authenticationPolicy.setOpenIdConnectAuthenticationPolicy(builder.build()); + this.authenticationPolicy = + new AuthenticationPolicyUnion().withOpenIdConnectAuthenticationPolicy(builder.build()); return this; } public ReferenceableAuthenticationPolicyBuilder use(String use) { - this.authenticationPolicyReference.setUse(use); + this.authenticationPolicyReference = new AuthenticationPolicyReference().withUse(use); return this; } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java index 955d9db5..f391e6c2 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/dsl/DSL.java @@ -319,7 +319,7 @@ public static Consumer errorFilter( * @param authName the name of a reusable authentication policy * @return an {@link AuthenticationConfigurer} that sets {@code use(authName)} */ - public static AuthenticationConfigurer auth(String authName) { + public static AuthenticationConfigurer use(String authName) { return a -> a.use(authName); } diff --git a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java index 5df3123c..1206b35c 100644 --- a/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java +++ b/fluent/spec/src/main/java/io/serverlessworkflow/fluent/spec/spi/CallHttpTaskFluent.java @@ -19,6 +19,7 @@ import io.serverlessworkflow.api.types.CallHTTP; import io.serverlessworkflow.api.types.Endpoint; import io.serverlessworkflow.api.types.EndpointConfiguration; +import io.serverlessworkflow.api.types.EndpointUri; import io.serverlessworkflow.api.types.HTTPArguments; import io.serverlessworkflow.api.types.HTTPHeaders; import io.serverlessworkflow.api.types.HTTPQuery; @@ -59,14 +60,17 @@ default SELF endpoint(URI endpoint) { default SELF endpoint(URI endpoint, Consumer auth) { final ReferenceableAuthenticationPolicyBuilder policy = new ReferenceableAuthenticationPolicyBuilder(); + final UriTemplate uriTemplate = new UriTemplate().withLiteralUri(endpoint); auth.accept(policy); ((CallHTTP) this.self().getTask()) .getWith() .setEndpoint( new Endpoint() .withEndpointConfiguration( - new EndpointConfiguration().withAuthentication(policy.build())) - .withUriTemplate(new UriTemplate().withLiteralUri(endpoint))); + new EndpointConfiguration() + .withUri(new EndpointUri().withLiteralEndpointURI(uriTemplate)) + .withAuthentication(policy.build())) + .withUriTemplate(uriTemplate)); return self(); } diff --git a/impl/core/pom.xml b/impl/core/pom.xml index 6fb99f6d..53fb5a65 100644 --- a/impl/core/pom.xml +++ b/impl/core/pom.xml @@ -28,5 +28,10 @@ com.cronutils cron-utils + + org.junit.jupiter + junit-jupiter-engine + test + diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java index 45156cdf..c250f7ee 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java @@ -30,6 +30,7 @@ import io.serverlessworkflow.impl.schema.SchemaValidator; import io.serverlessworkflow.impl.schema.SchemaValidatorFactory; import java.net.URI; +import java.net.URISyntaxException; import java.time.Duration; import java.util.Map; import java.util.Optional; @@ -221,11 +222,42 @@ public static final String checkSecret( .orElseThrow(() -> new IllegalStateException("Secret " + secretName + " does not exist")); } - public static URI concatURI(URI uri, String pathToAppend) { - return uri.getPath().endsWith("/") - ? uri.resolve(pathToAppend) - : URI.create( - uri.toString() + (pathToAppend.startsWith("/") ? pathToAppend : "/" + pathToAppend)); + public static URI concatURI(URI base, String pathToAppend) { + if (!isValid(pathToAppend)) { + return base; + } + + final URI child = URI.create(pathToAppend); + if (child.isAbsolute()) { + return child; + } + + String basePath = base.getPath(); + if (!isValid(basePath)) { + basePath = "/"; + } else if (!basePath.endsWith("/")) { + basePath = basePath + "/"; + } + + String relPath = child.getPath(); + if (relPath == null) { + relPath = ""; + } + while (relPath.startsWith("/")) { + relPath = relPath.substring(1); + } + + String finalPath = basePath + relPath; + + String query = child.getQuery(); + String fragment = child.getFragment(); + + try { + return new URI(base.getScheme(), base.getAuthority(), finalPath, query, fragment); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "Failed to build combined URI from base=" + base + " and path=" + pathToAppend, e); + } } public static WorkflowValueResolver getURISupplier( diff --git a/impl/core/src/test/java/io/serverlessworkflow/impl/WorkflowUtilsTest.java b/impl/core/src/test/java/io/serverlessworkflow/impl/WorkflowUtilsTest.java new file mode 100644 index 00000000..6fb73dc7 --- /dev/null +++ b/impl/core/src/test/java/io/serverlessworkflow/impl/WorkflowUtilsTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020-Present The Serverless 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 io.serverlessworkflow.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import org.junit.jupiter.api.Test; + +public class WorkflowUtilsTest { + @Test + void openApiServerWithTrailingSlashAndRootPath() { + URI base = URI.create("https://petstore3.swagger.io/api/v3/"); + URI path = URI.create("/pet/findByStatus"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://petstore3.swagger.io/api/v3/pet/findByStatus", result.toString()); + } + + @Test + void openApiServerWithoutTrailingSlashAndRootPath() { + URI base = URI.create("https://petstore3.swagger.io/api/v3"); + URI path = URI.create("/pet/findByStatus"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://petstore3.swagger.io/api/v3/pet/findByStatus", result.toString()); + } + + @Test + void baseWithSlashAndRelativePath() { + URI base = URI.create("https://example.com/api/v1/"); + URI path = URI.create("pets"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://example.com/api/v1/pets", result.toString()); + } + + @Test + void baseWithoutPathAndRootPath() { + URI base = URI.create("https://example.com"); + URI path = URI.create("/pets"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://example.com/pets", result.toString()); + } + + @Test + void absolutePathOverridesBase() { + URI base = URI.create("https://example.com/api"); + URI path = URI.create("https://other.example.com/foo"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://other.example.com/foo", result.toString()); + } + + @Test + void queryAndFragmentAreTakenFromPath() { + URI base = URI.create("https://example.com/api/v1"); + URI path = URI.create("/pets?status=available#top"); + + URI result = WorkflowUtils.concatURI(base, path.toString()); + + assertEquals("https://example.com/api/v1/pets?status=available#top", result.toString()); + } +} diff --git a/impl/http/pom.xml b/impl/http/pom.xml index e6c93080..a9bf229b 100644 --- a/impl/http/pom.xml +++ b/impl/http/pom.xml @@ -1,4 +1,5 @@ - + 4.0.0 io.serverlessworkflow @@ -8,9 +9,9 @@ serverlessworkflow-impl-http Serverless Workflow :: Impl :: HTTP - - io.serverlessworkflow - serverlessworkflow-impl-template-resolver - + + io.serverlessworkflow + serverlessworkflow-impl-template-resolver + \ No newline at end of file diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java index 3a9c8144..41eb0264 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java @@ -256,6 +256,8 @@ private static WorkflowValueResolver getTargetSupplier( WorkflowValueResolver uriSupplier, WorkflowValueResolver pathSupplier) { return (w, t, n) -> HttpClientResolver.client(w, t) - .target(uriSupplier.apply(w, t, n).resolve(pathSupplier.apply(w, t, n))); + .target( + WorkflowUtils.concatURI( + uriSupplier.apply(w, t, n), pathSupplier.apply(w, t, n).toString())); } } diff --git a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java index 0ea78ddb..86e7f89b 100644 --- a/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java +++ b/impl/openapi/src/main/java/io/serverlessworkflow/impl/executors/openapi/OpenAPIExecutor.java @@ -28,6 +28,7 @@ import io.serverlessworkflow.impl.executors.http.HttpExecutor; import io.serverlessworkflow.impl.executors.http.HttpExecutor.HttpExecutorBuilder; import io.serverlessworkflow.impl.resources.ResourceLoaderUtils; +import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; import java.util.Collection; import java.util.HashMap; @@ -52,7 +53,10 @@ public void init(CallOpenAPI task, WorkflowDefinition definition) { OpenAPIArguments with = task.getWith(); this.processor = new OpenAPIProcessor(with.getOperationId()); this.resource = with.getDocument(); - this.parameters = with.getParameters().getAdditionalProperties(); + this.parameters = + with.getParameters() != null && with.getParameters().getAdditionalProperties() != null + ? with.getParameters().getAdditionalProperties() + : Map.of(); this.builder = HttpExecutor.builder(definition) .withAuth(with.getAuthentication()) @@ -62,20 +66,27 @@ public void init(CallOpenAPI task, WorkflowDefinition definition) { @Override public CompletableFuture apply( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + + // In the same workflow, access to an already cached document + final OperationDefinition operationDefinition = + processor.parse( + workflowContext + .definition() + .resourceLoader() + .load( + resource, + ResourceLoaderUtils::readString, + workflowContext, + taskContext, + input)); + + fillHttpBuilder(workflowContext.definition().application(), operationDefinition); + // One executor per operation, even if the document is the same + // Me may refactor this even further to reuse the same executor (since the base URI is the same, + // but the path differs, although some use cases may require different client configurations for + // different paths...) Collection executors = - workflowContext - .definition() - .resourceLoader() - .>load( - resource, - r -> { - OperationDefinition o = processor.parse(ResourceLoaderUtils.readString(r)); - fillHttpBuilder(workflowContext.definition().application(), o); - return o.getServers().stream().map(s -> builder.build(s)).toList(); - }, - workflowContext, - taskContext, - input); + operationDefinition.getServers().stream().map(s -> builder.build(s)).toList(); Iterator iter = executors.iterator(); if (!iter.hasNext()) { @@ -91,9 +102,9 @@ public CompletableFuture apply( } private void fillHttpBuilder(WorkflowApplication application, OperationDefinition operation) { - Map headersMap = new HashMap(); - Map queryMap = new HashMap(); - Map pathParameters = new HashMap(); + Map headersMap = new HashMap<>(); + Map queryMap = new HashMap<>(); + Map pathParameters = new HashMap<>(); Map bodyParameters = new HashMap<>(parameters); for (Parameter parameter : operation.getParameters()) { @@ -110,6 +121,8 @@ private void fillHttpBuilder(WorkflowApplication application, OperationDefinitio } } + validateRequiredParameters(operation, headersMap, queryMap, pathParameters); + builder .withMethod(operation.getMethod()) .withPath(new OperationPathResolver(operation.getPath(), application, pathParameters)) @@ -124,4 +137,65 @@ private void param(String name, Map origMap, Map collectorMap.put(name, value); } } + + private void validateRequiredParameters( + OperationDefinition operation, + Map headersMap, + Map queryMap, + Map pathParameters) { + + StringBuilder missing = new StringBuilder(); + + for (Parameter parameter : operation.getParameters()) { + if (!Boolean.TRUE.equals(parameter.getRequired())) { + continue; + } + + String in = parameter.getIn(); + String name = parameter.getName(); + + Map targetMap = + switch (in) { + case "header" -> headersMap; + case "path" -> pathParameters; + case "query" -> queryMap; + default -> null; + }; + + if (targetMap == null) { + // We don't currently handle other "in" locations here (e.g., cookie). + // Treat as "not validated" instead of failing. + continue; + } + + boolean present = targetMap.containsKey(name); + + if (!present) { + // Try to satisfy the requirement using the OpenAPI default, if any + Schema schema = parameter.getSchema(); + Object defaultValue = schema != null ? schema.getDefault() : null; + + if (defaultValue != null) { + targetMap.put(name, defaultValue); + present = true; + } + } + + if (!present) { + if (!missing.isEmpty()) { + missing.append(", "); + } + missing.append(in).append(" parameter '").append(name).append("'"); + } + } + + if (!missing.isEmpty()) { + String operationId = + operation.getOperation().getOperationId() != null + ? operation.getOperation().getOperationId() + : ""; + throw new IllegalArgumentException( + "Missing required OpenAPI parameters for operation '" + operationId + "': " + missing); + } + } } diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java index 53593851..f68d6b8e 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/OpenAPITest.java @@ -52,49 +52,49 @@ public class OpenAPITest { private static String PROJECT_JSON_SUCCESS = """ - { - "success": true, - "data": { - "id": 55504, - "name": "CRM", - "code": "crm-20251111", - "ownerId": 12345, - "members": [ - 12345, - 67890 - ], - "created_at": "2025-09-20T00:58:50.170784Z" + { + "success": true, + "data": { + "id": 55504, + "name": "CRM", + "code": "crm-20251111", + "ownerId": 12345, + "members": [ + 12345, + 67890 + ], + "created_at": "2025-09-20T00:58:50.170784Z" + } } - } - """; + """; private static String PROJECT_JSON_FALSE = """ - { - "success": false, - "error": { - "code": "PROJECT_CONFLICT", - "message": "A project with the code "crm-2025" already exists.", - "details": null + { + "success": false, + "error": { + "code": "PROJECT_CONFLICT", + "message": "A project with the code crm-2025 already exists.", + "details": null + } } - } - """; + """; private static String PROJECT_GET_JSON_POSITIVE = """ - { - "success": true, - "data": { - "id": 40099, - "name": "Severus Calix", - "email": "severus.calix@hive-terra.example.com" - }, - "meta": { - "request_id": "req_terra123def456", - "timestamp": "999.M41-01-20T12:00:00Z" - } - } - """; + { + "success": true, + "data": { + "id": 40099, + "name": "Severus Calix", + "email": "severus.calix@hive-terra.example.com" + }, + "meta": { + "request_id": "req_terra123def456", + "timestamp": "999.M41-01-20T12:00:00Z" + } + } + """; @BeforeAll static void init() throws IOException { @@ -153,7 +153,7 @@ public void testOpenAPIBearerQueryInlinedBodyWithPositiveResponse() throws Excep RecordedRequest restRequest = restServer.takeRequest(); assertEquals("POST", restRequest.getMethod()); - assertTrue(restRequest.getPath().startsWith("/projects?")); + assertTrue(restRequest.getPath().startsWith("/api/v1/projects?")); assertTrue(restRequest.getPath().contains("notifyMembers=true")); assertTrue(restRequest.getPath().contains("validateOnly=false")); assertTrue(restRequest.getPath().contains("lang=en")); @@ -207,7 +207,7 @@ public void testOpenAPIBearerQueryInlinedBodyWithNegativeResponse() throws Excep RecordedRequest restRequest = restServer.takeRequest(); assertEquals("POST", restRequest.getMethod()); - assertTrue(restRequest.getPath().startsWith("/projects?")); + assertTrue(restRequest.getPath().startsWith("/api/v1/projects?")); assertTrue(restRequest.getPath().contains("notifyMembers=true")); assertTrue(restRequest.getPath().contains("validateOnly=false")); assertTrue(restRequest.getPath().contains("lang=en")); @@ -257,7 +257,7 @@ public void testOpenAPIGetWithPositiveResponse() throws Exception { private void assertData(Map result) throws InterruptedException { RecordedRequest restRequest = restServer.takeRequest(); assertEquals("GET", restRequest.getMethod()); - assertTrue(restRequest.getPath().startsWith("/users/40099?")); + assertTrue(restRequest.getPath().startsWith("/api/v1/users/40099?")); assertTrue(result.containsKey("data")); Map data = (Map) result.get("data"); @@ -307,7 +307,7 @@ public void testOpenAPIGetWithPositiveResponseAndVars() throws Exception { RecordedRequest restRequest = restServer.takeRequest(); assertEquals("GET", restRequest.getMethod()); - assertTrue(restRequest.getPath().startsWith("/users/40099?")); + assertTrue(restRequest.getPath().startsWith("/api/v1/users/40099?")); assertTrue(result.containsKey("data")); Map data = (Map) result.get("data");