From 4e437158dee1d6fcc82f8ee91b6d95de96f74c2e Mon Sep 17 00:00:00 2001 From: Alex Chen <238273288+alex-omophub@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:49:42 +0100 Subject: [PATCH 1/2] fix: encode concept lookup path segments --- src/omophub/resources/concepts.py | 10 ++++++-- tests/unit/resources/test_concepts.py | 33 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/omophub/resources/concepts.py b/src/omophub/resources/concepts.py index def06dc..6a50d24 100644 --- a/src/omophub/resources/concepts.py +++ b/src/omophub/resources/concepts.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, TypedDict +from urllib.parse import quote if TYPE_CHECKING: from .._request import AsyncRequest, Request @@ -56,6 +57,11 @@ class RelationshipsParams(TypedDict, total=False): page_size: int +def _path_segment(value: str) -> str: + """Percent-encode an API path segment without preserving slashes.""" + return quote(value, safe="") + + class Concepts: """Synchronous concepts resource.""" @@ -129,7 +135,7 @@ def get_by_code( params["vocab_release"] = vocab_release return self._request.get( - f"/concepts/by-code/{vocabulary_id}/{concept_code}", + f"/concepts/by-code/{_path_segment(vocabulary_id)}/{_path_segment(concept_code)}", params=params or None, ) @@ -404,7 +410,7 @@ async def get_by_code( params["vocab_release"] = vocab_release return await self._request.get( - f"/concepts/by-code/{vocabulary_id}/{concept_code}", + f"/concepts/by-code/{_path_segment(vocabulary_id)}/{_path_segment(concept_code)}", params=params or None, ) diff --git a/tests/unit/resources/test_concepts.py b/tests/unit/resources/test_concepts.py index 4023e55..df1143e 100644 --- a/tests/unit/resources/test_concepts.py +++ b/tests/unit/resources/test_concepts.py @@ -60,6 +60,22 @@ def test_get_concept_by_code( concept = sync_client.concepts.get_by_code("SNOMED", "44054006") assert concept["concept_id"] == 201826 + @respx.mock + def test_get_concept_by_code_encodes_path_segments( + self, sync_client: OMOPHub, mock_api_response: dict, base_url: str + ) -> None: + """Concept codes containing slashes remain one URL path segment.""" + route = respx.get(f"{base_url}/concepts/by-code/ICDO3/8032%2F3").mock( + return_value=Response(200, json=mock_api_response) + ) + + sync_client.concepts.get_by_code("ICDO3", "8032/3") + + assert route.called + assert str(route.calls[0].request.url).endswith( + "/concepts/by-code/ICDO3/8032%2F3" + ) + @respx.mock def test_batch_concepts( self, sync_client: OMOPHub, base_url: str, mock_concept: dict @@ -440,6 +456,23 @@ async def test_async_get_by_code( concept = await async_client.concepts.get_by_code("SNOMED", "44054006") assert concept["concept_id"] == 201826 + @pytest.mark.asyncio + @respx.mock + async def test_async_get_by_code_encodes_path_segments( + self, async_client: omophub.AsyncOMOPHub, mock_api_response: dict, base_url: str + ) -> None: + """Async concept codes containing slashes remain one URL path segment.""" + route = respx.get(f"{base_url}/concepts/by-code/ICDO3/8032%2F3").mock( + return_value=Response(200, json=mock_api_response) + ) + + await async_client.concepts.get_by_code("ICDO3", "8032/3") + + assert route.called + assert str(route.calls[0].request.url).endswith( + "/concepts/by-code/ICDO3/8032%2F3" + ) + @pytest.mark.asyncio @respx.mock async def test_async_batch_concepts( From 28a168a10b3cfc07eaaa52497e6032a3d5ae11fc Mon Sep 17 00:00:00 2001 From: Alex Chen <238273288+alex-omophub@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:14:47 +0100 Subject: [PATCH 2/2] docs: require raw concept lookup values --- src/omophub/resources/concepts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/omophub/resources/concepts.py b/src/omophub/resources/concepts.py index 6a50d24..b760b27 100644 --- a/src/omophub/resources/concepts.py +++ b/src/omophub/resources/concepts.py @@ -114,8 +114,8 @@ def get_by_code( """Get a concept by vocabulary and code. Args: - vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") - concept_code: The concept code within the vocabulary + vocabulary_id: The raw, URL-unencoded vocabulary ID (e.g., "SNOMED", "ICD10CM") + concept_code: The raw, URL-unencoded concept code within the vocabulary include_relationships: Include related concepts (parents/children) include_synonyms: Include concept synonyms include_hierarchy: Include hierarchy information @@ -389,8 +389,8 @@ async def get_by_code( """Get a concept by vocabulary and code. Args: - vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") - concept_code: The concept code within the vocabulary + vocabulary_id: The raw, URL-unencoded vocabulary ID (e.g., "SNOMED", "ICD10CM") + concept_code: The raw, URL-unencoded concept code within the vocabulary include_relationships: Include related concepts (parents/children) include_synonyms: Include concept synonyms include_hierarchy: Include hierarchy information