Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions httprest/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""API."""

import urllib.parse
from typing import Optional as _Optional
from typing import Union as _Union

Expand Down Expand Up @@ -31,7 +32,7 @@ def __init__(
def _request(
self,
method: str,
endpoint: str,
endpoint: _Optional[str] = None,
data: _Optional[_Union[dict, bytes]] = None,
json: _Optional[dict] = None,
headers: _Optional[dict] = None,
Expand All @@ -42,7 +43,7 @@ def _request(
# pylint: disable=too-many-arguments
"""Make API request.

:param endpoint: API endpoint. Will be appended to the base URL
:param endpoint: API endpoint. Will be joined with the base URL

Other parameters are the same as for the `HTTPClient.request` method
"""
Expand All @@ -57,8 +58,8 @@ def _request(
cert=cert,
)

def _build_url(self, endpoint: str) -> str:
return f"{self._base_url}/{endpoint.strip('/')}"
def _build_url(self, endpoint: _Optional[str]) -> str:
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urllib.parse.urljoin() requires both arguments to be strings, but endpoint can now be None. This will cause a TypeError when endpoint=None is passed. The function should handle the None case, for example:

def _build_url(self, endpoint: _Optional[str]) -> str:
    if endpoint is None:
        return self._base_url
    return urllib.parse.urljoin(self._base_url, endpoint)

Additionally, note that urljoin("http://fake.com", "") returns "http://fake.com" (without trailing slash), which matches the expected behavior in the test.

Suggested change
def _build_url(self, endpoint: _Optional[str]) -> str:
def _build_url(self, endpoint: _Optional[str]) -> str:
if endpoint is None:
return self._base_url

Copilot uses AI. Check for mistakes.
return urllib.parse.urljoin(self._base_url, endpoint)

def __str__(self) -> str:
return f"{self.__class__.__name__}(base_url='{self._base_url}')"
7 changes: 5 additions & 2 deletions tests/unit/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
from typing import Optional

from httprest import API
from httprest.http import HTTPResponse
from httprest.http.fake_client import FakeHTTPClient


class _TestAPI(API):
"""API client for tests."""

def make_call(self):
def make_call(
self, endpoint: Optional[str] = "/example/endpoint/"
) -> HTTPResponse:
"""Make API call."""
return self._request(
"POST", "/example/endpoint/", json={"k": "v"}, headers={"h": "v"}
"POST", endpoint, json={"k": "v"}, headers={"h": "v"}
)


Expand Down
21 changes: 20 additions & 1 deletion tests/unit/test_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Tests for the API client."""

import typing
Comment thread
litteratum marked this conversation as resolved.

import pytest

from httprest.http import HTTPResponse

from .fakes import FakeHTTPClient, build_api
Expand All @@ -19,6 +23,21 @@ def test_api_call():
"headers": {"h": "v"},
"json": {"k": "v"},
"method": "POST",
"url": "http://fake.com/example/endpoint",
"url": "http://fake.com/example/endpoint/",
},
]


@pytest.mark.parametrize("endpoint", ["", None])
def test_url_without_endpoint(endpoint: typing.Optional[str]):
"""Test for request URL when endpoint is not specified.

The base URL must be used.
"""
base = "http://fake.com"
comps = build_api(
base_url=base,
http_client=FakeHTTPClient(responses=[HTTPResponse(200, b"", {})]),
)
comps.api.make_call(endpoint=endpoint)
assert comps.http_client.history[0]["url"] == base
Loading