Repository: apache/streampipes (java)

Issue:
Python client throws exception on connection
### Body

## Exception
```
requests.exceptions.HTTPError: 404 Client Error: Not Found for url: http://localhost:8082/streampipes-backend/api/v2/info/versions/
```
## How to resolve
Remove the trailing `/` from the versions URL

### StreamPipes Committer

I acknowledge that I am a maintainer/committer of the Apache StreamPipes project.

Pull request description:
fix(#2329): python client throws exception on connection
<!--
  ~ Licensed to the Apache Software Foundation (ASF) under one or more
  ~ contributor license agreements.  See the NOTICE file distributed with
  ~ this work for additional information regarding copyright ownership.
  ~ The ASF licenses this file to You 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.
  ~
  -->
  
  <!--
Thanks for contributing! Here are some tips you can follow to help us incorporate your contribution quickly and easily:
1. If this is your first time, please read our contributor guidelines:
    - https://streampipes.apache.org/getinvolved.html
    - https://cwiki.apache.org/confluence/display/STREAMPIPES/Getting+Started
2. Make sure the PR title is formatted like: `[#<GitHub issue id>] PR title ...`
3. If the PR is unfinished, add '[WIP]' in your PR title, e.g., `[WIP][#<GitHub issue id>] PR title ...`.
4. Please write your PR title to summarize what this PR proposes/fixes.
5. Link the PR to the corresponding GitHub issue (if present) in the `Development` section in the right menu bar. 
6. Be sure to keep the PR description updated to reflect all changes.
7. If possible, provide a concise example to reproduce the issue for a faster review.
8. Make sure tests pass via `mvn clean install`.
9. (Optional) If the contribution is large, please file an Apache ICLA
    - http://apache.org/licenses/icla.pdf
-->

### Purpose
<!--
Please clarify what changes you are proposing and describe how those changes will address the issue.
Furthermore, describe potential consequences the changes might have.
-->
Fix #2329 
This PR fixes the bug reported in #2329 which was caused by trailing slashes are no longer allowed since #2276.
In addition, the Python client is now able to connect to StreamPipes instances started only in development mode.

### Remarks
<!--
Is there anything left we need to pay attention on?
Are there some references that might be important? E.g. links to Confluence, or discussions
on the mailing list or GitHub.
-->
PR introduces (a) breaking change(s): no

PR introduces (a) deprecation(s): no


Patch:
diff --git a/streampipes-client-python/Makefile b/streampipes-client-python/Makefile
index 682c6c56d4..dc8d5b7ac2 100644
--- a/streampipes-client-python/Makefile
+++ b/streampipes-client-python/Makefile
@@ -17,7 +17,6 @@
 
 .PHONY: check
 check: mypy lint unit-tests
-	poetry run interrogate -c pyproject.toml
 
 .PHONY: doc
 doc:
diff --git a/streampipes-client-python/streampipes/endpoint/endpoint.py b/streampipes-client-python/streampipes/endpoint/endpoint.py
index fa96df79ae..b3572d9d11 100644
--- a/streampipes-client-python/streampipes/endpoint/endpoint.py
+++ b/streampipes-client-python/streampipes/endpoint/endpoint.py
@@ -205,9 +205,12 @@ class APIEndpoint(Endpoint):
             The specified resource as an instance of the corresponding model class.
         """
 
-        response = self._make_request(
-            request_method=self._parent_client.request_session.get, url=f"{self.build_url()}/{identifier}"
-        )
+        query_url = f"{self.build_url()}"
+
+        if identifier:
+            query_url += f"/{identifier}"
+
+        response = self._make_request(request_method=self._parent_client.request_session.get, url=query_url)
 
         return self._container_cls._resource_cls()(**response.json())
 
@@ -226,7 +229,7 @@ class APIEndpoint(Endpoint):
 
         self._make_request(
             request_method=self._parent_client.request_session.post,
-            url=f"{self.build_url()}/",
+            url=f"{self.build_url()}",
             data=json.dumps(resource.to_dict(use_source_names=True)),
             headers={"Content-type": "application/json"},
         )
diff --git a/streampipes-client-python/streampipes/model/resource/version.py b/streampipes-client-python/streampipes/model/resource/version.py
index 0ec557321f..734955c136 100644
--- a/streampipes-client-python/streampipes/model/resource/version.py
+++ b/streampipes-client-python/streampipes/model/resource/version.py
@@ -19,9 +19,9 @@ __all__ = [
     "Version",
 ]
 
-from typing import Dict
+from typing import Dict, Optional
 
-from pydantic import StrictStr
+from pydantic import StrictStr, validator
 
 from streampipes.model.resource.resource import Resource
 
@@ -41,4 +41,12 @@ class Version(Resource):
         """
         return self.to_dict(use_source_names=False)
 
-    backend_version: StrictStr
+    backend_version: Optional[StrictStr] = None
+
+    @validator("backend_version", always=True)
+    def validate_backend_version(cls, backend_version):
+        """Validates the backend version of the StreamPipes.
+        Sets 'development' if none is returned since this the behavior of StreamPipes backend running
+        in development mode.
+        """
+        return backend_version or "development"
diff --git a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/locales/LabelGenerator.java b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/locales/LabelGenerator.java
index 8e47e20923..a1be4db1d0 100644
--- a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/locales/LabelGenerator.java
+++ b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/locales/LabelGenerator.java
@@ -44,9 +44,9 @@ public class LabelGenerator<T extends NamedStreamPipesEntity> {
 
   private static final Logger LOG = LoggerFactory.getLogger(LabelGenerator.class);
 
-  private static final String Delimiter = ".";
-  private static final String Title = "title";
-  private static final String Description = "description";
+  protected static final String DELIMITER = ".";
+  protected static final String TITLE = "title";
+  protected static final String DESCRIPTION = "description";
 
   private T desc;
 
@@ -56,34 +56,51 @@ public class LabelGenerator<T extends NamedStreamPipesEntity> {
 
   public T generateLabels() throws IOException {
     if (existsLocalesFile()) {
-      Properties props = makeProperties();
+      Properties props = laodResourceAndMakeProperties();
       desc.setName(getTitle(props, desc.getAppId()));
       desc.setDescription(getDescription(props, desc.getAppId()));
 
       if (isAdapter()) {
-        ((AdapterDescription) desc).getConfig().forEach(sp -> generateLabels(props, sp));
+        ((AdapterDescription) desc).getConfig()
+                                   .forEach(sp -> generateLabels(props, sp));
       }
 
       if (isConsumable()) {
-        ((ConsumableStreamPipesEntity) desc).getStaticProperties().forEach(sp -> {
-          generateLabels(props, sp);
-        });
+        ((ConsumableStreamPipesEntity) desc).getStaticProperties()
+                                            .forEach(sp -> {
+                                              generateLabels(props, sp);
+                                            });
       }
 
       if (isDataProcessor()) {
-        ((DataProcessorDescription) desc).getOutputStrategies().forEach(os -> {
-          if (os instanceof AppendOutputStrategy) {
-            ((AppendOutputStrategy) os).getEventProperties().forEach(ep -> {
-              ep.setLabel(getTitle(props, ep.getRuntimeId()));
-              ep.setDescription(getDescription(props, ep.getRuntimeId()));
-            });
-          } else if (os instanceof FixedOutputStrategy) {
-            ((FixedOutputStrategy) os).getEventProperties().forEach(ep -> {
-              ep.setLabel(getTitle(props, ep.getRuntimeId()));
-              ep.setDescription(getDescription(props, ep.getRuntimeId()));
-            });
-          }
-        });
+        ((DataProcessorDescription) desc).getOutputStrategies()
+                                         .forEach(os -> {
+                                           if (os instanceof AppendOutputStrategy) {
+                                             ((AppendOutputStrategy) os).getEventProperties()
+                                                                        .forEach(ep -> {
+                                                                          ep.setLabel(getTitle(
+                                                                              props,
+                                                                              ep.getRuntimeId()
+                                                                          ));
+                                                                          ep.setDescription(getDescription(
+                                                                              props,
+                                                                              ep.getRuntimeId()
+                                                                          ));
+                                                                        });
+                                           } else if (os instanceof FixedOutputStrategy) {
+                                             ((FixedOutputStrategy) os).getEventProperties()
+                                                                       .forEach(ep -> {
+                                                                         ep.setLabel(getTitle(
+                                                                             props,
+                                                                             ep.getRuntimeId()
+                                                                         ));
+                                                                         ep.setDescription(getDescription(
+                                                                             props,
+                                                                             ep.getRuntimeId()
+                                                                         ));
+                                                                       });
+                                           }
+                                         });
       }
     } else {
       LOG.error("Could not find assets directory to generate labels for app id:" + desc.getAppId());
@@ -92,31 +109,52 @@ public class LabelGenerator<T extends NamedStreamPipesEntity> {
     return desc;
   }
 
+
+  /**
+   * Returns the tile of the element description based on the data of the resource files
+   */
+  public String getElementTitle() throws IOException {
+    var props = checkIfResourceFileExistsAndMakeProperties();
+    return getTitle(props, desc.getAppId());
+  }
+
+  /**
+   * Returns the description of the element description based on the data of the resource files
+   */
+  public String getElementDescription() throws IOException {
+    var props = checkIfResourceFileExistsAndMakeProperties();
+    return getDescription(props, desc.getAppId());
+  }
+
+
   private StaticProperty generateLabels(Properties props, StaticProperty sp) {
     sp.setLabel(getTitle(props, sp.getInternalName(), sp.getLabel()));
     sp.setDescription(getDescription(props, sp.getInternalName(), sp.getDescription()));
     if (sp instanceof CollectionStaticProperty) {
 
       if (((CollectionStaticProperty) sp).getMembers() != null) {
-        ((CollectionStaticProperty) sp).getMembers().forEach(a -> {
-          generateLabels(props, a);
-        });
+        ((CollectionStaticProperty) sp).getMembers()
+                                       .forEach(a -> {
+                                         generateLabels(props, a);
+                                       });
       } else {
         ((StaticPropertyGroup) ((CollectionStaticProperty) sp).getStaticPropertyTemplate()).getStaticProperties()
-            .forEach(a -> {
-              generateLabels(props, a);
-            });
+                                                                                           .forEach(a -> {
+                                                                                             generateLabels(props, a);
+                                                                                           });
       }
 
     } else if (sp instanceof StaticPropertyGroup) {
-      ((StaticPropertyGroup) sp).getStaticProperties().forEach(g -> {
-        g.setLabel(getTitle(props, g.getInternalName(), g.getLabel()));
-        g.setDescription(getDescription(props, g.getInternalName(), g.getDescription()));
-      });
+      ((StaticPropertyGroup) sp).getStaticProperties()
+                                .forEach(g -> {
+                                  g.setLabel(getTitle(props, g.getInternalName(), g.getLabel()));
+                                  g.setDescription(getDescription(props, g.getInternalName(), g.getDescription()));
+                                });
     } else if (sp instanceof StaticPropertyAlternatives) {
-      ((StaticPropertyAlternatives) sp).getAlternatives().forEach(a -> {
-        generateLabels(props, a);
-      });
+      ((StaticPropertyAlternatives) sp).getAlternatives()
+                                       .forEach(a -> {
+                                         generateLabels(props, a);
+                                       });
     } else if (sp instanceof StaticPropertyAlternative) {
       if (((StaticPropertyAlternative) sp).getStaticProperty() != null) {
         generateLabels(props, ((StaticPropertyAlternative) sp).getStaticProperty());
@@ -126,26 +164,37 @@ public class LabelGenerator<T extends NamedStreamPipesEntity> {
     return sp;
   }
 
-  private Properties makeProperties() throws IOException {
+  protected Properties checkIfResourceFileExistsAndMakeProperties() throws IOException {
+    throwIOExceptionIfResourceDoesNotExist();
+    return laodResourceAndMakeProperties();
+  }
+
+  private Properties laodResourceAndMakeProperties() throws IOException {
     Properties props = new Properties();
     props.load(new InputStreamReader(loadResource(), StandardCharsets.UTF_8));
 
     return props;
   }
 
-  public String getElementTitle() throws IOException {
-    Properties props = makeProperties();
-    return getTitle(props, desc.getAppId());
+  protected boolean existsLocalesFile() {
+    return this.getClass()
+               .getClassLoader()
+               .getResource(
+                   getPath()
+               ) != null;
   }
 
-  public String getElementDescription() throws IOException {
-    Properties props = makeProperties();
-    return getDescription(props, desc.getAppId());
+  private void throwIOExceptionIfResourceDoesNotExist() throws IOException {
+    if (!existsLocalesFile()) {
+      throw new IOException(
+          "Could not find assets directory to generate labels for app id: %s".formatted(desc.getAppId())
+      );
+    }
   }
 
-  private boolean existsLocalesFile() {
-    return this.getClass().getClassLoader().getResourceAsStream(makePath(desc,
-        this.desc.getIncludedLocales().get(0))) != null;
+  private String getPath() {
+    return makePath(desc, desc.getIncludedLocales()
+                              .get(0));
   }
 
   private boolean isConsumable() {
@@ -161,36 +210,41 @@ public class LabelGenerator<T extends NamedStreamPipesEntity> {
   }
 
   private InputStream loadResource() {
-    if (desc.getIncludedLocales().size() > 0) {
-      return getResourceFile(desc.getIncludedLocales().get(0));
+    if (desc.getIncludedLocales()
+            .size() > 0) {
+      return getResourceFile(desc.getIncludedLocales()
+                                 .get(0));
     } else {
-      throw new IllegalArgumentException("Could not find any language files");
+      throw new IllegalArgumentException("Could not find any language files for %s".formatted(desc.getAppId()));
     }
   }
 
   private String getTitle(Properties props, String id, String defaultValue) {
-    return getValue(props, Title, id, defaultValue);
+    return getValue(props, TITLE, id, defaultValue);
   }
 
   private String getTitle(Properties props, String id) {
-    return getValue(props, Title, id, "");
+    return getValue(props, TITLE, id, "");
   }
 
   private String getDescription(Properties props, String id) {
-    return getValue(props, Description, id, "");
+    return getValue(props, DESCRIPTION, id, "");
   }
 
   private String getDescription(Properties props, String id, String defaultValue) {
-    return getValue(props, Description, id, defaultValue);
+    return getValue(props, DESCRIPTION, id, defaultValue);
   }
 
   private String getValue(Properties props, String type, String id, String defaultValue) {
-    return props.getProperty(id + Delimiter + type, defaultValue);
+    return props.getProperty(id + DELIMITER + type, defaultValue);
   }
 
 
   private InputStream getResourceFile(String filename) {
-    return this.getClass().getClassLoader().getResourceAsStream(makePath(desc, filename));
+    var path = makePath(desc, filename);
+    return this.getClass()
+               .getClassLoader()
+               .getResourceAsStream(path);
   }
 
 }
diff --git a/streampipes-rest-extensions/src/main/java/org/apache/streampipes/rest/extensions/html/page/WelcomePageGenerator.java b/streampipes-rest-extensions/src/main/java/org/apache/streampipes/rest/extensions/html/page/WelcomePageGenerator.java
index d4ff6fe381..8aa99727e7 100644
--- a/streampipes-rest-extensions/src/main/java/org/apache/streampipes/rest/extensions/html/page/WelcomePageGenerator.java
+++ b/streampipes-rest-extensions/src/main/java/org/apache/streampipes/rest/extensions/html/page/WelcomePageGenerator.java
@@ -32,6 +32,9 @@ import org.apache.streampipes.model.graph.DataSinkDescription;
 import org.apache.streampipes.rest.extensions.html.model.Description;
 import org.apache.streampipes.sdk.utils.Assets;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -40,6 +43,8 @@ import java.util.List;
 
 public class WelcomePageGenerator {
 
+  private static final Logger LOG = LoggerFactory.getLogger(WelcomePageGenerator.class);
+
   protected List<Description> descriptions;
   protected Collection<IStreamPipesPipelineElement<?>> pipelineElements;
   protected Collection<StreamPipesAdapter> adapters;
@@ -129,7 +134,7 @@ public class WelcomePageGenerator {
         desc.setName(lg.getElementTitle());
         desc.setDescription(lg.getElementDescription());
       } catch (IOException e) {
-        e.printStackTrace();
+        LOG.error("Error while updating description of %s".formatted(entity.getAppId()), e);
       }
     }
   }


Test patch:
diff --git a/streampipes-client-python/tests/client/test_endpoint.py b/streampipes-client-python/tests/client/test_endpoint.py
index c610339ee1..2f63a781ed 100644
--- a/streampipes-client-python/tests/client/test_endpoint.py
+++ b/streampipes-client-python/tests/client/test_endpoint.py
@@ -235,7 +235,7 @@ class TestStreamPipesEndpoints(TestCase):
         client.dataStreamApi.post(DataStream(**self.data_stream_get))
 
         http_session_mock.post.assert_called_with(
-            url="https://localhost:80/streampipes-backend/api/v2/streams/",
+            url="https://localhost:80/streampipes-backend/api/v2/streams",
             data=json.dumps(self.data_stream_get),
             headers={"Content-type": "application/json"},
         )
diff --git a/streampipes-client-python/tests/client/test_versions.py b/streampipes-client-python/tests/client/test_versions.py
new file mode 100644
index 0000000000..0dc78a3f3f
--- /dev/null
+++ b/streampipes-client-python/tests/client/test_versions.py
@@ -0,0 +1,48 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+from unittest import TestCase
+from unittest.mock import patch, MagicMock, call
+
+from streampipes.client import StreamPipesClient
+from streampipes.client.config import StreamPipesClientConfig
+from streampipes.client.credential_provider import StreamPipesApiKeyCredentials
+
+
+class TestVersions(TestCase):
+
+    @patch("streampipes.client.client.Session", autospec=True)
+    def test_get_development_version(self, http_session: MagicMock) -> None:
+        http_session_mock = MagicMock()
+        http_session_mock.get.return_value.json.return_value = {"backendVersion": None}
+        http_session.return_value = http_session_mock
+
+        client = StreamPipesClient(
+            client_config=StreamPipesClientConfig(
+                credential_provider=StreamPipesApiKeyCredentials(username="user", api_key="key"),
+                host_address="localhost"
+            )
+        )
+
+        result = client.versionApi.get("")
+
+        http_session.assert_has_calls(
+            [call().get(url="https://localhost:80/streampipes-backend/api/v2/info/versions")],
+            any_order=True
+        )
+
+        self.assertEqual("development", result.backend_version)
diff --git a/streampipes-extensions-management/src/test/java/org/apache/streampipes/extensions/management/locales/LabelGeneratorTest.java b/streampipes-extensions-management/src/test/java/org/apache/streampipes/extensions/management/locales/LabelGeneratorTest.java
new file mode 100644
index 0000000000..5e193da3a8
--- /dev/null
+++ b/streampipes-extensions-management/src/test/java/org/apache/streampipes/extensions/management/locales/LabelGeneratorTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.streampipes.extensions.management.locales;
+
+import org.apache.streampipes.model.base.NamedStreamPipesEntity;
+
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.Properties;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class LabelGeneratorTest {
+
+  private static final String TEST_APP_ID = "test-app-id";
+
+  @Test
+  public void getElementDescriptionThrowExceptiojn() {
+    var labelGenerator = getLabelGeneratorWithoutLocalesFile();
+    assertThrows(IOException.class, labelGenerator::getElementDescription);
+  }
+
+  @Test
+  public void getElementDescriptionReturnsCorrectDescription() throws IOException {
+    var expectedDescription = "test-description";
+    var properties = getProperties(LabelGenerator.DESCRIPTION, expectedDescription);
+
+    var labelGenerator = getLabelGeneratorWithProperties(properties);
+
+    assertEquals(expectedDescription, labelGenerator.getElementDescription());
+  }
+
+  @Test
+  public void getElementTitleThrowExceptiojn() {
+    var labelGenerator = getLabelGeneratorWithoutLocalesFile();
+    assertThrows(IOException.class, labelGenerator::getElementTitle);
+  }
+
+  @Test
+  public void getElementTitleReturnsCorrectDescription() throws IOException {
+    var expectedTitle = "test-title";
+    var properties = getProperties(LabelGenerator.TITLE, expectedTitle);
+
+    var labelGenerator = getLabelGeneratorWithProperties(properties);
+
+    assertEquals(expectedTitle, labelGenerator.getElementTitle());
+  }
+
+
+  private LabelGenerator getLabelGeneratorWithoutLocalesFile() {
+    var mockDescription = Mockito.mock(NamedStreamPipesEntity.class);
+    when(mockDescription.getAppId()).thenReturn(TEST_APP_ID);
+
+    var labelGenerator = new LabelGenerator(mockDescription);
+    var labelGeneratorMock = spy(labelGenerator);
+    doReturn(false).when(labelGeneratorMock)
+                   .existsLocalesFile();
+    return labelGeneratorMock;
+  }
+
+  private LabelGenerator getLabelGeneratorWithProperties(Properties properties) throws IOException {
+    var mockDescription = Mockito.mock(NamedStreamPipesEntity.class);
+    when(mockDescription.getAppId()).thenReturn(TEST_APP_ID);
+
+    var labelGenerator = new LabelGenerator(mockDescription);
+    var labelGeneratorMock = spy(labelGenerator);
+    doReturn(properties).when(labelGeneratorMock)
+                        .checkIfResourceFileExistsAndMakeProperties();
+    return labelGeneratorMock;
+  }
+
+  private Properties getProperties(String key, String value) {
+    var result = new Properties();
+    result.setProperty(TEST_APP_ID + LabelGenerator.DELIMITER + key, value);
+
+    return result;
+  }
+}
\ No newline at end of file