From ab7f4cd7433a69e28b909d45bea197fa318587bf Mon Sep 17 00:00:00 2001 From: Sumit-ai-dev Date: Tue, 5 May 2026 02:01:41 +0530 Subject: [PATCH 1/4] Fix trailing slash in agent URL (#768) Normalize agent_url by appending a trailing slash in the registration serializer and organization service. This ensures correct URL construction when communicating with agents. Signed-off-by: Sumit-ai-dev --- src/api-engine/auth/serializers.py | 2 + src/api-engine/auth/tests.py | 78 +++++++++++++++++++++++++- src/api-engine/organization/service.py | 4 ++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/api-engine/auth/serializers.py b/src/api-engine/auth/serializers.py index 58036e45b..44184e583 100644 --- a/src/api-engine/auth/serializers.py +++ b/src/api-engine/auth/serializers.py @@ -34,6 +34,8 @@ def validate_org_name(org_name: str) -> str: @staticmethod def validate_agent_url(agent_url: str) -> str: + if not agent_url.endswith("/"): + agent_url += "/" if Organization.objects.filter(agent_url=agent_url).exists(): raise serializers.ValidationError("Agent already exists!") validate_url(agent_url) diff --git a/src/api-engine/auth/tests.py b/src/api-engine/auth/tests.py index 7ce503c2d..58ef075bb 100644 --- a/src/api-engine/auth/tests.py +++ b/src/api-engine/auth/tests.py @@ -1,3 +1,79 @@ from django.test import TestCase +from urllib.parse import urljoin -# Create your tests here. + +class AgentUrlNormalizationTests(TestCase): + """ + Unit tests for Issue #768: Deal with the trailing slash of agent URL. + Tests the URL normalization logic applied in validate_agent_url. + """ + + def _normalize(self, url: str) -> str: + """Simulates the normalization logic added to validate_agent_url.""" + if not url.endswith("/"): + url += "/" + return url + + def test_url_without_trailing_slash_gets_slash_appended(self): + """A URL without a trailing slash should have one added.""" + url = "http://127.0.0.1:5001" + result = self._normalize(url) + self.assertTrue(result.endswith("/"), f"Expected trailing slash, got: {result}") + self.assertEqual(result, "http://127.0.0.1:5001/") + + def test_url_with_trailing_slash_is_unchanged(self): + """A URL that already ends with a slash should not be modified.""" + url = "http://127.0.0.1:5001/" + result = self._normalize(url) + self.assertEqual(result, "http://127.0.0.1:5001/") + # Should not double-add the slash + self.assertFalse(result.endswith("//"), f"Got double slash: {result}") + + def test_url_with_path_without_slash(self): + """A URL with a base path but no trailing slash should get one.""" + url = "http://example.com/api" + result = self._normalize(url) + self.assertEqual(result, "http://example.com/api/") + + def test_url_with_path_and_slash(self): + """A URL with a base path and trailing slash should be unchanged.""" + url = "http://example.com/api/" + result = self._normalize(url) + self.assertEqual(result, "http://example.com/api/") + + def test_urljoin_correct_after_normalization_simple(self): + """After normalization, urljoin should produce the correct health check URL.""" + url = "http://127.0.0.1:5001" + normalized = self._normalize(url) + result = urljoin(normalized, "health") + self.assertEqual(result, "http://127.0.0.1:5001/health") + + def test_urljoin_correct_after_normalization_with_path(self): + """After normalization, urljoin with a path should produce the correct URL.""" + url = "http://example.com/api" + normalized = self._normalize(url) + result = urljoin(normalized, "health") + self.assertEqual(result, "http://example.com/api/health") + + def test_urljoin_without_normalization_breaks_path(self): + """ + This test demonstrates the ORIGINAL BUG: + Without normalization, urljoin("http://example.com/api", "health") + incorrectly returns "http://example.com/health" instead of + "http://example.com/api/health". + """ + broken_url = "http://example.com/api" # No trailing slash (original bug) + broken_result = urljoin(broken_url, "health") + self.assertNotEqual( + broken_result, + "http://example.com/api/health", + "Bug confirmed: urljoin without trailing slash drops the path segment." + ) + self.assertEqual(broken_result, "http://example.com/health") + + def test_urljoin_organizations_endpoint_correct_after_normalization(self): + """Test that the 'organizations' endpoint is constructed correctly.""" + url = "http://example.com/api" + normalized = self._normalize(url) + result = urljoin(normalized, "organizations") + self.assertEqual(result, "http://example.com/api/organizations") diff --git a/src/api-engine/organization/service.py b/src/api-engine/organization/service.py index 754783d8e..c018432b6 100644 --- a/src/api-engine/organization/service.py +++ b/src/api-engine/organization/service.py @@ -6,6 +6,8 @@ def create_organization(org_name: str, agent_url: str) -> Organization: + if not agent_url.endswith("/"): + agent_url += "/" _create_organization(org_name, agent_url) organization = Organization(name=org_name, agent_url=agent_url) organization.save() @@ -13,5 +15,7 @@ def create_organization(org_name: str, agent_url: str) -> Organization: def _create_organization(org_name: str, agent_url: str): + if not agent_url.endswith("/"): + agent_url += "/" requests.get(urljoin(agent_url, "health")).raise_for_status() requests.post(urljoin(agent_url, "organizations"), json=dict(name=org_name)).raise_for_status() From cb3a4caf1316ae8b9d9ebc57c67089864c8eef7e Mon Sep 17 00:00:00 2001 From: Sumit-ai-dev Date: Tue, 5 May 2026 02:11:26 +0530 Subject: [PATCH 2/4] Address Copilot review comments on #768 fix - Extract normalize_agent_url to common/utils.py using urllib.parse for safe path-only normalization (preserves query strings/fragments) - Remove duplicate trailing-slash check from _create_organization - Check both normalized and unnormalized forms in uniqueness query - Update tests to import real normalize_agent_url instead of re-implementing it Signed-off-by: Sumit-ai-dev --- src/api-engine/auth/serializers.py | 8 +- src/api-engine/auth/tests.py | 104 ++++++++++--------------- src/api-engine/common/utils.py | 13 ++++ src/api-engine/organization/service.py | 6 +- 4 files changed, 62 insertions(+), 69 deletions(-) diff --git a/src/api-engine/auth/serializers.py b/src/api-engine/auth/serializers.py index 44184e583..0027a5dec 100644 --- a/src/api-engine/auth/serializers.py +++ b/src/api-engine/auth/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from common.validators import validate_host, validate_url +from common.utils import normalize_agent_url from api.lib.pki import CryptoConfig, CryptoGen from organization.models import Organization from organization.service import create_organization @@ -34,9 +35,10 @@ def validate_org_name(org_name: str) -> str: @staticmethod def validate_agent_url(agent_url: str) -> str: - if not agent_url.endswith("/"): - agent_url += "/" - if Organization.objects.filter(agent_url=agent_url).exists(): + agent_url = normalize_agent_url(agent_url) + if Organization.objects.filter( + agent_url__in=[agent_url, agent_url.rstrip("/")] + ).exists(): raise serializers.ValidationError("Agent already exists!") validate_url(agent_url) diff --git a/src/api-engine/auth/tests.py b/src/api-engine/auth/tests.py index 58ef075bb..ba28c67c3 100644 --- a/src/api-engine/auth/tests.py +++ b/src/api-engine/auth/tests.py @@ -1,79 +1,59 @@ from django.test import TestCase from urllib.parse import urljoin +from common.utils import normalize_agent_url + class AgentUrlNormalizationTests(TestCase): """ Unit tests for Issue #768: Deal with the trailing slash of agent URL. - Tests the URL normalization logic applied in validate_agent_url. + Tests the real normalize_agent_url utility from common.utils. """ - def _normalize(self, url: str) -> str: - """Simulates the normalization logic added to validate_agent_url.""" - if not url.endswith("/"): - url += "/" - return url + # ── Normalization ───────────────────────────────────────────────────── def test_url_without_trailing_slash_gets_slash_appended(self): - """A URL without a trailing slash should have one added.""" - url = "http://127.0.0.1:5001" - result = self._normalize(url) - self.assertTrue(result.endswith("/"), f"Expected trailing slash, got: {result}") - self.assertEqual(result, "http://127.0.0.1:5001/") + self.assertEqual(normalize_agent_url("http://127.0.0.1:5001"), "http://127.0.0.1:5001/") def test_url_with_trailing_slash_is_unchanged(self): - """A URL that already ends with a slash should not be modified.""" - url = "http://127.0.0.1:5001/" - result = self._normalize(url) + result = normalize_agent_url("http://127.0.0.1:5001/") self.assertEqual(result, "http://127.0.0.1:5001/") - # Should not double-add the slash - self.assertFalse(result.endswith("//"), f"Got double slash: {result}") + self.assertFalse(result.endswith("//")) def test_url_with_path_without_slash(self): - """A URL with a base path but no trailing slash should get one.""" - url = "http://example.com/api" - result = self._normalize(url) - self.assertEqual(result, "http://example.com/api/") - - def test_url_with_path_and_slash(self): - """A URL with a base path and trailing slash should be unchanged.""" - url = "http://example.com/api/" - result = self._normalize(url) - self.assertEqual(result, "http://example.com/api/") - - def test_urljoin_correct_after_normalization_simple(self): - """After normalization, urljoin should produce the correct health check URL.""" - url = "http://127.0.0.1:5001" - normalized = self._normalize(url) - result = urljoin(normalized, "health") - self.assertEqual(result, "http://127.0.0.1:5001/health") - - def test_urljoin_correct_after_normalization_with_path(self): - """After normalization, urljoin with a path should produce the correct URL.""" - url = "http://example.com/api" - normalized = self._normalize(url) - result = urljoin(normalized, "health") - self.assertEqual(result, "http://example.com/api/health") - - def test_urljoin_without_normalization_breaks_path(self): - """ - This test demonstrates the ORIGINAL BUG: - Without normalization, urljoin("http://example.com/api", "health") - incorrectly returns "http://example.com/health" instead of - "http://example.com/api/health". - """ - broken_url = "http://example.com/api" # No trailing slash (original bug) - broken_result = urljoin(broken_url, "health") - self.assertNotEqual( - broken_result, - "http://example.com/api/health", - "Bug confirmed: urljoin without trailing slash drops the path segment." - ) - self.assertEqual(broken_result, "http://example.com/health") - - def test_urljoin_organizations_endpoint_correct_after_normalization(self): - """Test that the 'organizations' endpoint is constructed correctly.""" + self.assertEqual(normalize_agent_url("http://example.com/api"), "http://example.com/api/") + + def test_url_with_path_and_slash_unchanged(self): + self.assertEqual(normalize_agent_url("http://example.com/api/"), "http://example.com/api/") + + def test_query_string_preserved(self): + """Query string must not be corrupted by normalization.""" + result = normalize_agent_url("http://example.com/api?key=val") + self.assertEqual(result, "http://example.com/api/?key=val") + self.assertNotIn("?key=val/", result) + + def test_normalization_is_idempotent(self): + """Applying normalize twice should not double-add the slash.""" url = "http://example.com/api" - normalized = self._normalize(url) - result = urljoin(normalized, "organizations") - self.assertEqual(result, "http://example.com/api/organizations") + self.assertEqual(normalize_agent_url(normalize_agent_url(url)), normalize_agent_url(url)) + + # ── Bug Reproduction ────────────────────────────────────────────────── + + def test_bug_urljoin_without_normalization_drops_path(self): + """Proves the original bug: urljoin drops path segment without trailing slash.""" + result = urljoin("http://example.com/api", "health") + self.assertEqual(result, "http://example.com/health") # Not /api/health + + # ── Fix Validation ──────────────────────────────────────────────────── + + def test_fix_health_endpoint_simple(self): + url = normalize_agent_url("http://127.0.0.1:5001") + self.assertEqual(urljoin(url, "health"), "http://127.0.0.1:5001/health") + + def test_fix_health_endpoint_with_path(self): + url = normalize_agent_url("http://example.com/api") + self.assertEqual(urljoin(url, "health"), "http://example.com/api/health") + + def test_fix_organizations_endpoint(self): + url = normalize_agent_url("http://example.com/api") + self.assertEqual(urljoin(url, "organizations"), "http://example.com/api/organizations") diff --git a/src/api-engine/common/utils.py b/src/api-engine/common/utils.py index 9ecee9ad3..59afa65a7 100644 --- a/src/api-engine/common/utils.py +++ b/src/api-engine/common/utils.py @@ -1,4 +1,5 @@ import uuid +from urllib.parse import urlparse, urlunparse def make_uuid(): @@ -15,3 +16,15 @@ def separate_upper_class(class_name): x += c i += 1 return "_".join(x.strip().split(" ")) + + +def normalize_agent_url(url: str) -> str: + """Ensure the URL path ends with a trailing slash. + + Uses urllib.parse to safely modify only the path component, + preserving any query strings or fragments unchanged. + """ + parsed = urlparse(url) + if not parsed.path.endswith("/"): + parsed = parsed._replace(path=parsed.path + "/") + return urlunparse(parsed) diff --git a/src/api-engine/organization/service.py b/src/api-engine/organization/service.py index c018432b6..06638d1e1 100644 --- a/src/api-engine/organization/service.py +++ b/src/api-engine/organization/service.py @@ -2,12 +2,12 @@ import requests +from common.utils import normalize_agent_url from organization.models import Organization def create_organization(org_name: str, agent_url: str) -> Organization: - if not agent_url.endswith("/"): - agent_url += "/" + agent_url = normalize_agent_url(agent_url) _create_organization(org_name, agent_url) organization = Organization(name=org_name, agent_url=agent_url) organization.save() @@ -15,7 +15,5 @@ def create_organization(org_name: str, agent_url: str) -> Organization: def _create_organization(org_name: str, agent_url: str): - if not agent_url.endswith("/"): - agent_url += "/" requests.get(urljoin(agent_url, "health")).raise_for_status() requests.post(urljoin(agent_url, "organizations"), json=dict(name=org_name)).raise_for_status() From 1a7ed817bf5b45d20a1994d05c63e02012975de6 Mon Sep 17 00:00:00 2001 From: Sumit-ai-dev Date: Tue, 5 May 2026 02:22:07 +0530 Subject: [PATCH 3/4] Address second round of Copilot review comments on #768 - Added denormalize_agent_url to common/utils.py using urllib.parse for proper path-aware variant generation (fixes rstrip issue with query strings) - Updated uniqueness check to use denormalize_agent_url instead of rstrip - Added Organization.save() override to normalize agent_url at model level, covering existing DB records on next write - Switched tests from TestCase to SimpleTestCase (no DB access needed) Signed-off-by: Sumit-ai-dev --- src/api-engine/auth/serializers.py | 4 ++-- src/api-engine/auth/tests.py | 4 ++-- src/api-engine/common/utils.py | 11 +++++++++++ src/api-engine/organization/models.py | 8 +++++++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/api-engine/auth/serializers.py b/src/api-engine/auth/serializers.py index 0027a5dec..19c243e5d 100644 --- a/src/api-engine/auth/serializers.py +++ b/src/api-engine/auth/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from common.validators import validate_host, validate_url -from common.utils import normalize_agent_url +from common.utils import normalize_agent_url, denormalize_agent_url from api.lib.pki import CryptoConfig, CryptoGen from organization.models import Organization from organization.service import create_organization @@ -37,7 +37,7 @@ def validate_org_name(org_name: str) -> str: def validate_agent_url(agent_url: str) -> str: agent_url = normalize_agent_url(agent_url) if Organization.objects.filter( - agent_url__in=[agent_url, agent_url.rstrip("/")] + agent_url__in=[agent_url, denormalize_agent_url(agent_url)] ).exists(): raise serializers.ValidationError("Agent already exists!") validate_url(agent_url) diff --git a/src/api-engine/auth/tests.py b/src/api-engine/auth/tests.py index ba28c67c3..f28880e90 100644 --- a/src/api-engine/auth/tests.py +++ b/src/api-engine/auth/tests.py @@ -1,10 +1,10 @@ -from django.test import TestCase +from django.test import SimpleTestCase from urllib.parse import urljoin from common.utils import normalize_agent_url -class AgentUrlNormalizationTests(TestCase): +class AgentUrlNormalizationTests(SimpleTestCase): """ Unit tests for Issue #768: Deal with the trailing slash of agent URL. Tests the real normalize_agent_url utility from common.utils. diff --git a/src/api-engine/common/utils.py b/src/api-engine/common/utils.py index 59afa65a7..20f0a36d8 100644 --- a/src/api-engine/common/utils.py +++ b/src/api-engine/common/utils.py @@ -28,3 +28,14 @@ def normalize_agent_url(url: str) -> str: if not parsed.path.endswith("/"): parsed = parsed._replace(path=parsed.path + "/") return urlunparse(parsed) + + +def denormalize_agent_url(url: str) -> str: + """Return the URL with the trailing slash stripped from the path component. + + Used to generate the unnormalized variant for duplicate DB lookups. + """ + parsed = urlparse(url) + stripped = parsed.path.rstrip("/") or "/" + parsed = parsed._replace(path=stripped) + return urlunparse(parsed) diff --git a/src/api-engine/organization/models.py b/src/api-engine/organization/models.py index 61955c6c1..8655ae7aa 100644 --- a/src/api-engine/organization/models.py +++ b/src/api-engine/organization/models.py @@ -1,7 +1,7 @@ from django.db import models from common.validators import validate_host, validate_url -from common.utils import make_uuid +from common.utils import make_uuid, normalize_agent_url class Organization(models.Model): @@ -23,5 +23,11 @@ class Organization(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) + def save(self, *args, **kwargs): + """Normalize agent_url before saving to ensure trailing slash is always present.""" + if self.agent_url: + self.agent_url = normalize_agent_url(self.agent_url) + super().save(*args, **kwargs) + class Meta: ordering = ("-created_at",) From a8af94668ca266424a0aa60be86cb3723023c7cd Mon Sep 17 00:00:00 2001 From: Sumit-ai-dev Date: Tue, 5 May 2026 09:55:19 +0530 Subject: [PATCH 4/4] Refactor: Handle trailing slash at point of use (#768) Addressed maintainer feedback by reverting input normalization and instead fixing the URL construction logic. Created a safe_urljoin utility to guarantee correct path concatenation regardless of the trailing slash in the base agent_url. Updated all service layers to use this new utility. Signed-off-by: Sumit-ai-dev --- src/api-engine/auth/serializers.py | 6 +- src/api-engine/auth/tests.py | 122 ++++++++++++++----------- src/api-engine/chaincode/service.py | 26 +++--- src/api-engine/channel/service.py | 6 +- src/api-engine/common/utils.py | 32 +++---- src/api-engine/node/service.py | 10 +- src/api-engine/organization/models.py | 10 +- src/api-engine/organization/service.py | 8 +- 8 files changed, 112 insertions(+), 108 deletions(-) diff --git a/src/api-engine/auth/serializers.py b/src/api-engine/auth/serializers.py index 19c243e5d..58036e45b 100644 --- a/src/api-engine/auth/serializers.py +++ b/src/api-engine/auth/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from common.validators import validate_host, validate_url -from common.utils import normalize_agent_url, denormalize_agent_url from api.lib.pki import CryptoConfig, CryptoGen from organization.models import Organization from organization.service import create_organization @@ -35,10 +34,7 @@ def validate_org_name(org_name: str) -> str: @staticmethod def validate_agent_url(agent_url: str) -> str: - agent_url = normalize_agent_url(agent_url) - if Organization.objects.filter( - agent_url__in=[agent_url, denormalize_agent_url(agent_url)] - ).exists(): + if Organization.objects.filter(agent_url=agent_url).exists(): raise serializers.ValidationError("Agent already exists!") validate_url(agent_url) diff --git a/src/api-engine/auth/tests.py b/src/api-engine/auth/tests.py index f28880e90..ff7d11e48 100644 --- a/src/api-engine/auth/tests.py +++ b/src/api-engine/auth/tests.py @@ -1,59 +1,77 @@ -from django.test import SimpleTestCase -from urllib.parse import urljoin +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from common.utils import normalize_agent_url +from django.test import SimpleTestCase +from common.utils import safe_urljoin -class AgentUrlNormalizationTests(SimpleTestCase): +class SafeUrljoinTests(SimpleTestCase): """ Unit tests for Issue #768: Deal with the trailing slash of agent URL. - Tests the real normalize_agent_url utility from common.utils. - """ - - # ── Normalization ───────────────────────────────────────────────────── - - def test_url_without_trailing_slash_gets_slash_appended(self): - self.assertEqual(normalize_agent_url("http://127.0.0.1:5001"), "http://127.0.0.1:5001/") - - def test_url_with_trailing_slash_is_unchanged(self): - result = normalize_agent_url("http://127.0.0.1:5001/") - self.assertEqual(result, "http://127.0.0.1:5001/") - self.assertFalse(result.endswith("//")) - - def test_url_with_path_without_slash(self): - self.assertEqual(normalize_agent_url("http://example.com/api"), "http://example.com/api/") - def test_url_with_path_and_slash_unchanged(self): - self.assertEqual(normalize_agent_url("http://example.com/api/"), "http://example.com/api/") - - def test_query_string_preserved(self): - """Query string must not be corrupted by normalization.""" - result = normalize_agent_url("http://example.com/api?key=val") - self.assertEqual(result, "http://example.com/api/?key=val") - self.assertNotIn("?key=val/", result) - - def test_normalization_is_idempotent(self): - """Applying normalize twice should not double-add the slash.""" - url = "http://example.com/api" - self.assertEqual(normalize_agent_url(normalize_agent_url(url)), normalize_agent_url(url)) - - # ── Bug Reproduction ────────────────────────────────────────────────── - - def test_bug_urljoin_without_normalization_drops_path(self): - """Proves the original bug: urljoin drops path segment without trailing slash.""" - result = urljoin("http://example.com/api", "health") - self.assertEqual(result, "http://example.com/health") # Not /api/health - - # ── Fix Validation ──────────────────────────────────────────────────── - - def test_fix_health_endpoint_simple(self): - url = normalize_agent_url("http://127.0.0.1:5001") - self.assertEqual(urljoin(url, "health"), "http://127.0.0.1:5001/health") - - def test_fix_health_endpoint_with_path(self): - url = normalize_agent_url("http://example.com/api") - self.assertEqual(urljoin(url, "health"), "http://example.com/api/health") + The fix uses safe_urljoin() instead of urllib.parse.urljoin() to correctly + preserve path segments in the base URL regardless of whether the user + stored the agent_url with or without a trailing slash. + """ - def test_fix_organizations_endpoint(self): - url = normalize_agent_url("http://example.com/api") - self.assertEqual(urljoin(url, "organizations"), "http://example.com/api/organizations") + def test_base_without_slash_health_endpoint(self): + """safe_urljoin correctly handles base URL without trailing slash.""" + self.assertEqual( + safe_urljoin("http://127.0.0.1:5001", "health"), + "http://127.0.0.1:5001/health" + ) + + def test_base_with_slash_health_endpoint(self): + """safe_urljoin works correctly when base URL already has trailing slash.""" + self.assertEqual( + safe_urljoin("http://127.0.0.1:5001/", "health"), + "http://127.0.0.1:5001/health" + ) + + def test_base_with_path_without_slash(self): + """safe_urljoin preserves path segment when base has no trailing slash.""" + self.assertEqual( + safe_urljoin("http://example.com/api", "health"), + "http://example.com/api/health" + ) + + def test_base_with_path_and_slash(self): + """safe_urljoin works correctly when base path already ends with slash.""" + self.assertEqual( + safe_urljoin("http://example.com/api/", "health"), + "http://example.com/api/health" + ) + + def test_organizations_endpoint(self): + self.assertEqual( + safe_urljoin("http://example.com/api", "organizations"), + "http://example.com/api/organizations" + ) + + def test_nodes_status_endpoint(self): + self.assertEqual( + safe_urljoin("http://example.com/api", "nodes/status"), + "http://example.com/api/nodes/status" + ) + + def test_channels_endpoint(self): + self.assertEqual( + safe_urljoin("http://example.com/api", "channels"), + "http://example.com/api/channels" + ) + + def test_chaincodes_install_endpoint(self): + self.assertEqual( + safe_urljoin("http://example.com/api", "chaincodes/install"), + "http://example.com/api/chaincodes/install" + ) + + def test_original_urljoin_bug(self): + """Documents the original bug: stdlib urljoin drops path segments.""" + from urllib.parse import urljoin + # This is the bug: /api is silently dropped + self.assertEqual(urljoin("http://example.com/api", "health"), "http://example.com/health") + # safe_urljoin fixes it + self.assertEqual(safe_urljoin("http://example.com/api", "health"), "http://example.com/api/health") diff --git a/src/api-engine/chaincode/service.py b/src/api-engine/chaincode/service.py index dc68330dc..ec5d5dde9 100644 --- a/src/api-engine/chaincode/service.py +++ b/src/api-engine/chaincode/service.py @@ -6,7 +6,7 @@ import tarfile import threading from typing import Optional, List, Any, Dict, Tuple -from urllib.parse import urljoin +from common.utils import safe_urljoin import requests from django.db import transaction @@ -30,9 +30,9 @@ def get_chaincode(pk: str) -> Optional[Chaincode]: def get_chaincode_status(organization: Organization, chaincode: Chaincode) -> str: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() response = requests.get( - urljoin(agent_url, "chaincodes/status"), + safe_urljoin(agent_url, "chaincodes/status"), params=dict( name=chaincode.name, package_id=chaincode.package_id, @@ -46,9 +46,9 @@ def get_chaincode_status(organization: Organization, chaincode: Chaincode) -> st def get_chaincode_commit_readiness(organization: Organization, chaincode: Chaincode) -> str: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() response = requests.get( - urljoin(agent_url, "chaincodes/commit/readiness"), + safe_urljoin(agent_url, "chaincodes/commit/readiness"), params=dict( name=chaincode.name, version=chaincode.version, @@ -73,9 +73,9 @@ def create_chaincode( init_required: bool = False, signature_policy: str = None) -> Chaincode: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() response = requests.post( - urljoin(agent_url, "chaincodes"), + safe_urljoin(agent_url, "chaincodes"), data=dict( name=name, version=version, @@ -123,9 +123,9 @@ def metadata_exists(file) -> bool: def install_chaincode(organization: Organization, chaincode: Chaincode) -> None: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() requests.put( - urljoin(agent_url, "chaincodes/install"), + safe_urljoin(agent_url, "chaincodes/install"), data=dict( name=chaincode.name, version=chaincode.version, @@ -142,9 +142,9 @@ def approve_chaincode( organization: Organization, chaincode: Chaincode) -> None: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() requests.put( - urljoin(agent_url, "chaincodes/approve"), + safe_urljoin(agent_url, "chaincodes/approve"), json=dict( name=chaincode.name, version=chaincode.version, @@ -161,9 +161,9 @@ def commit_chaincode( organization: Organization, chaincode: Chaincode) -> None: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() requests.put( - urljoin(agent_url, "chaincodes/commit"), + safe_urljoin(agent_url, "chaincodes/commit"), json=dict( name=chaincode.name, version=chaincode.version, diff --git a/src/api-engine/channel/service.py b/src/api-engine/channel/service.py index f4ecc74b8..5246d2d52 100644 --- a/src/api-engine/channel/service.py +++ b/src/api-engine/channel/service.py @@ -1,5 +1,5 @@ import logging -from urllib.parse import urljoin +from common.utils import safe_urljoin import requests @@ -13,9 +13,9 @@ def create( channel_organization: Organization, channel_name: str) -> Channel: agent_url = channel_organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() requests.post( - urljoin(agent_url, "channels"), + safe_urljoin(agent_url, "channels"), json=dict(name=channel_name) ).raise_for_status() diff --git a/src/api-engine/common/utils.py b/src/api-engine/common/utils.py index 20f0a36d8..10e114a13 100644 --- a/src/api-engine/common/utils.py +++ b/src/api-engine/common/utils.py @@ -1,5 +1,5 @@ import uuid -from urllib.parse import urlparse, urlunparse +from urllib.parse import urljoin def make_uuid(): @@ -18,24 +18,20 @@ def separate_upper_class(class_name): return "_".join(x.strip().split(" ")) -def normalize_agent_url(url: str) -> str: - """Ensure the URL path ends with a trailing slash. - - Uses urllib.parse to safely modify only the path component, - preserving any query strings or fragments unchanged. - """ - parsed = urlparse(url) - if not parsed.path.endswith("/"): - parsed = parsed._replace(path=parsed.path + "/") - return urlunparse(parsed) +def safe_urljoin(base: str, path: str) -> str: + """Join a base URL and a path, ensuring the base URL's path is preserved. + Unlike urllib.parse.urljoin, this function guarantees the base URL ends + with a trailing slash before joining, so that any path segments in the + base are not silently dropped. -def denormalize_agent_url(url: str) -> str: - """Return the URL with the trailing slash stripped from the path component. + Example: + safe_urljoin("http://host/api", "health") + => "http://host/api/health" (correct) - Used to generate the unnormalized variant for duplicate DB lookups. + urljoin("http://host/api", "health") + => "http://host/health" (wrong — drops /api) """ - parsed = urlparse(url) - stripped = parsed.path.rstrip("/") or "/" - parsed = parsed._replace(path=stripped) - return urlunparse(parsed) + if not base.endswith("/"): + base = base + "/" + return urljoin(base, path) diff --git a/src/api-engine/node/service.py b/src/api-engine/node/service.py index 3ebfd53f5..f37438388 100644 --- a/src/api-engine/node/service.py +++ b/src/api-engine/node/service.py @@ -3,7 +3,7 @@ import os import sys from typing import Optional, Dict, Any, List -from urllib.parse import urljoin +from common.utils import safe_urljoin from zipfile import ZipFile import docker @@ -26,9 +26,9 @@ def get_node(node_id: str) -> Optional[Node]: def get_node_status(organization: Organization, node: Node) -> str: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() return requests.get( - urljoin(agent_url, "nodes/status"), + safe_urljoin(agent_url, "nodes/status"), params=dict(type=node.type, name=node.name)).json()["status"] @@ -42,8 +42,8 @@ def organization_orderer_exists(organization: Organization) -> bool: def create(organization: Organization, node_type: Node.Type, node_name: str) -> Node: agent_url = organization.agent_url - requests.get(urljoin(agent_url, "health")).raise_for_status() - response = requests.post(urljoin(agent_url, "nodes"), json=dict(type=node_type, name=node_name)) + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() + response = requests.post(safe_urljoin(agent_url, "nodes"), json=dict(type=node_type, name=node_name)) response.raise_for_status() node = Node( diff --git a/src/api-engine/organization/models.py b/src/api-engine/organization/models.py index 8655ae7aa..fd6767bc7 100644 --- a/src/api-engine/organization/models.py +++ b/src/api-engine/organization/models.py @@ -1,9 +1,11 @@ from django.db import models from common.validators import validate_host, validate_url -from common.utils import make_uuid, normalize_agent_url +from common.utils import make_uuid +# Create your models here. + class Organization(models.Model): id = models.UUIDField( primary_key=True, @@ -23,11 +25,5 @@ class Organization(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) - def save(self, *args, **kwargs): - """Normalize agent_url before saving to ensure trailing slash is always present.""" - if self.agent_url: - self.agent_url = normalize_agent_url(self.agent_url) - super().save(*args, **kwargs) - class Meta: ordering = ("-created_at",) diff --git a/src/api-engine/organization/service.py b/src/api-engine/organization/service.py index 06638d1e1..ffca54289 100644 --- a/src/api-engine/organization/service.py +++ b/src/api-engine/organization/service.py @@ -1,13 +1,11 @@ -from urllib.parse import urljoin +from common.utils import safe_urljoin import requests -from common.utils import normalize_agent_url from organization.models import Organization def create_organization(org_name: str, agent_url: str) -> Organization: - agent_url = normalize_agent_url(agent_url) _create_organization(org_name, agent_url) organization = Organization(name=org_name, agent_url=agent_url) organization.save() @@ -15,5 +13,5 @@ def create_organization(org_name: str, agent_url: str) -> Organization: def _create_organization(org_name: str, agent_url: str): - requests.get(urljoin(agent_url, "health")).raise_for_status() - requests.post(urljoin(agent_url, "organizations"), json=dict(name=org_name)).raise_for_status() + requests.get(safe_urljoin(agent_url, "health")).raise_for_status() + requests.post(safe_urljoin(agent_url, "organizations"), json=dict(name=org_name)).raise_for_status()