fix: problem with saving files from APIRequestComponent (#5627)
* fix problem with saving files from APIRequestComponent * moving api_request component test to appropriate subdir
This commit is contained in:
parent
3e482dbc29
commit
517961d281
3 changed files with 188 additions and 39 deletions
|
|
@ -3,11 +3,10 @@ import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import validators
|
import validators
|
||||||
|
|
@ -177,12 +176,13 @@ class APIRequestComponent(Component):
|
||||||
)
|
)
|
||||||
|
|
||||||
redirection_history = [
|
redirection_history = [
|
||||||
{"url": str(redirect.url), "status_code": redirect.status_code} for redirect in response.history
|
{
|
||||||
|
"url": redirect.headers.get("Location", str(redirect.url)),
|
||||||
|
"status_code": redirect.status_code,
|
||||||
|
}
|
||||||
|
for redirect in response.history
|
||||||
]
|
]
|
||||||
|
|
||||||
if response.is_redirect:
|
|
||||||
redirection_history.append({"url": str(response.url), "status_code": response.status_code})
|
|
||||||
|
|
||||||
is_binary, file_path = self._response_info(response, with_file_path=save_to_file)
|
is_binary, file_path = self._response_info(response, with_file_path=save_to_file)
|
||||||
response_headers = self._headers_to_dict(response.headers)
|
response_headers = self._headers_to_dict(response.headers)
|
||||||
|
|
||||||
|
|
@ -196,11 +196,12 @@ class APIRequestComponent(Component):
|
||||||
if file_path:
|
if file_path:
|
||||||
async with async_open(file_path, mode, encoding=encoding) as f:
|
async with async_open(file_path, mode, encoding=encoding) as f:
|
||||||
await f.write(response.content if is_binary else response.text)
|
await f.write(response.content if is_binary else response.text)
|
||||||
|
await f.flush() # Ensure the file is flushed to disk
|
||||||
|
metadata["file_path"] = str(file_path)
|
||||||
|
|
||||||
if include_httpx_metadata:
|
if include_httpx_metadata:
|
||||||
metadata.update(
|
metadata.update(
|
||||||
{
|
{
|
||||||
"file_path": str(file_path),
|
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"response_headers": response_headers,
|
"response_headers": response_headers,
|
||||||
|
|
@ -347,7 +348,7 @@ class APIRequestComponent(Component):
|
||||||
extracted_filename = filename_match.group(1)
|
extracted_filename = filename_match.group(1)
|
||||||
# Ensure the filename is unique
|
# Ensure the filename is unique
|
||||||
if (component_temp_dir / extracted_filename).exists():
|
if (component_temp_dir / extracted_filename).exists():
|
||||||
timestamp = datetime.now(ZoneInfo("UTC")).strftime("%Y%m%d%H%M%S%f")
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
||||||
filename = f"{timestamp}-{extracted_filename}"
|
filename = f"{timestamp}-{extracted_filename}"
|
||||||
else:
|
else:
|
||||||
filename = extracted_filename
|
filename = extracted_filename
|
||||||
|
|
@ -355,7 +356,7 @@ class APIRequestComponent(Component):
|
||||||
# Step 3: Infer file extension or use part of the request URL if no filename
|
# Step 3: Infer file extension or use part of the request URL if no filename
|
||||||
if not filename:
|
if not filename:
|
||||||
# Extract the last segment of the URL path
|
# Extract the last segment of the URL path
|
||||||
url_path = urlparse(str(response.request.url)).path
|
url_path = urlparse(str(response.request.url) if response.request else "").path
|
||||||
base_name = Path(url_path).name # Get the last segment of the path
|
base_name = Path(url_path).name # Get the last segment of the path
|
||||||
if not base_name: # If the path ends with a slash or is empty
|
if not base_name: # If the path ends with a slash or is empty
|
||||||
base_name = "response"
|
base_name = "response"
|
||||||
|
|
@ -366,7 +367,7 @@ class APIRequestComponent(Component):
|
||||||
extension = ".bin" if is_binary else ".txt" # Default extensions
|
extension = ".bin" if is_binary else ".txt" # Default extensions
|
||||||
|
|
||||||
# Combine the base name with timestamp and extension
|
# Combine the base name with timestamp and extension
|
||||||
timestamp = datetime.now(ZoneInfo("UTC")).strftime("%Y%m%d%H%M%S%f")
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
||||||
filename = f"{timestamp}-{base_name}{extension}"
|
filename = f"{timestamp}-{base_name}{extension}"
|
||||||
|
|
||||||
# Step 4: Define the full file path
|
# Step 4: Define the full file path
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import aiofiles.os
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from aiofile import async_open
|
||||||
|
from httpx import Response
|
||||||
|
from langflow.components import data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_request():
|
||||||
|
# This fixture provides an instance of APIRequest for each test case
|
||||||
|
return data.APIRequestComponent()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_curl(api_request):
|
||||||
|
# Arrange
|
||||||
|
field_value = (
|
||||||
|
"curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'"
|
||||||
|
)
|
||||||
|
build_config = {
|
||||||
|
"method": {"value": ""},
|
||||||
|
"urls": {"value": []},
|
||||||
|
"headers": {},
|
||||||
|
"body": {},
|
||||||
|
}
|
||||||
|
# Act
|
||||||
|
new_build_config = api_request.parse_curl(field_value, build_config.copy())
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert new_build_config["method"]["value"] == "GET"
|
||||||
|
assert new_build_config["urls"]["value"] == ["https://example.com/api/test"]
|
||||||
|
assert new_build_config["headers"]["value"] == {"Content-Type": "application/json"}
|
||||||
|
assert new_build_config["body"]["value"] == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
# HTTPx Metadata testing
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("include_metadata", "expected_properties"),
|
||||||
|
[
|
||||||
|
(False, {"source", "result"}),
|
||||||
|
(True, {"source", "result", "headers", "status_code", "response_headers", "redirection_history"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@respx.mock
|
||||||
|
async def test_httpx_metadata_behavior(api_request, include_metadata, expected_properties):
|
||||||
|
# Mocking a successful GET request with headers and a redirection
|
||||||
|
url = "https://example.com/api/test"
|
||||||
|
redirected_url = "https://example.com/api/redirect"
|
||||||
|
response_content = {"key": "value"}
|
||||||
|
respx.get(url).mock(return_value=Response(303, headers={"Location": redirected_url}))
|
||||||
|
respx.get(redirected_url).mock(
|
||||||
|
return_value=Response(200, json=response_content, headers={"Custom-Header": "HeaderValue"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make the request
|
||||||
|
result = await api_request.make_request(
|
||||||
|
client=httpx.AsyncClient(),
|
||||||
|
method="GET",
|
||||||
|
url=url,
|
||||||
|
save_to_file=False,
|
||||||
|
include_httpx_metadata=include_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check returned metadata
|
||||||
|
metadata = result.data
|
||||||
|
assert set(metadata.keys()) == expected_properties, f"Unexpected properties: {set(metadata.keys())}"
|
||||||
|
|
||||||
|
if include_metadata:
|
||||||
|
# Validate individual fields
|
||||||
|
assert metadata["source"] == url
|
||||||
|
assert metadata["headers"] is None
|
||||||
|
assert metadata["status_code"] == 200
|
||||||
|
assert metadata["response_headers"]["custom-header"] == "HeaderValue"
|
||||||
|
|
||||||
|
# Validate redirection history
|
||||||
|
assert metadata["redirection_history"] == [
|
||||||
|
{"url": redirected_url, "status_code": 303}
|
||||||
|
], "Redirection history is incorrect"
|
||||||
|
|
||||||
|
# Validate result
|
||||||
|
assert metadata["result"] == response_content, "Response content mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
# Save to File testing
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("save_to_file", "expected_properties"),
|
||||||
|
[
|
||||||
|
(False, {"source", "result"}),
|
||||||
|
(True, {"source", "file_path"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@respx.mock
|
||||||
|
async def test_save_to_file_behavior(api_request, save_to_file, expected_properties):
|
||||||
|
# Mocking a successful GET request with a response body
|
||||||
|
url = "https://example.com/api/test"
|
||||||
|
response_content = "Test response content"
|
||||||
|
respx.get(url).mock(return_value=Response(200, content=response_content))
|
||||||
|
|
||||||
|
# Make the request
|
||||||
|
result = await api_request.make_request(
|
||||||
|
client=httpx.AsyncClient(),
|
||||||
|
method="GET",
|
||||||
|
url=url,
|
||||||
|
save_to_file=save_to_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check returned metadata
|
||||||
|
metadata = result.data
|
||||||
|
assert set(metadata.keys()) == expected_properties, f"Unexpected properties: {set(metadata.keys())}"
|
||||||
|
|
||||||
|
if save_to_file:
|
||||||
|
# Validate that file_path exists in metadata
|
||||||
|
assert "file_path" in metadata, "file_path is missing in metadata"
|
||||||
|
file_path = metadata["file_path"]
|
||||||
|
|
||||||
|
# Validate that the file exists and its content matches the response
|
||||||
|
assert await aiofiles.os.path.exists(file_path), "Saved file does not exist"
|
||||||
|
async with async_open(file_path, "r") as f:
|
||||||
|
file_content = await f.read()
|
||||||
|
assert file_content == response_content, "File content does not match response content"
|
||||||
|
|
||||||
|
# Cleanup the file
|
||||||
|
await aiofiles.os.remove(file_path)
|
||||||
|
else:
|
||||||
|
# Validate that result exists in metadata
|
||||||
|
assert "result" in metadata, "result is missing in metadata"
|
||||||
|
assert metadata["result"] == response_content.encode("utf-8"), "Response content mismatch in metadata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_info_binary_content(api_request):
|
||||||
|
response = Mock()
|
||||||
|
response.headers = {"Content-Type": "application/octet-stream"}
|
||||||
|
is_binary, file_path = api_request._response_info(response, with_file_path=False)
|
||||||
|
assert is_binary is True
|
||||||
|
assert file_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_info_non_binary_content(api_request):
|
||||||
|
response = Mock()
|
||||||
|
response.headers = {"Content-Type": "text/plain"}
|
||||||
|
is_binary, file_path = api_request._response_info(response, with_file_path=False)
|
||||||
|
assert is_binary is False
|
||||||
|
assert file_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_info_filename_from_content_disposition(api_request):
|
||||||
|
response = Mock()
|
||||||
|
response.headers = {
|
||||||
|
"Content-Disposition": 'attachment; filename="thisfile.txt"',
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
}
|
||||||
|
response.request = Mock()
|
||||||
|
response.request.url = "https://example.com/testfile"
|
||||||
|
|
||||||
|
is_binary, file_path = api_request._response_info(response, with_file_path=True)
|
||||||
|
|
||||||
|
assert is_binary is False
|
||||||
|
assert file_path.parent == Path(tempfile.gettempdir()) / "APIRequestComponent"
|
||||||
|
assert file_path.name.endswith("thisfile.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_info_default_filename(api_request):
|
||||||
|
response = Mock()
|
||||||
|
response.headers = {"Content-Type": "text/plain"}
|
||||||
|
response.request = Mock()
|
||||||
|
response.request.url = "https://example.com/testfile"
|
||||||
|
|
||||||
|
is_binary, file_path = api_request._response_info(response, with_file_path=True)
|
||||||
|
|
||||||
|
assert is_binary is False
|
||||||
|
assert file_path.parent == Path(tempfile.gettempdir()) / "APIRequestComponent"
|
||||||
|
assert file_path.name.endswith("testfile.txt")
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import pytest
|
|
||||||
from langflow.components import data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def api_request():
|
|
||||||
# This fixture provides an instance of APIRequest for each test case
|
|
||||||
return data.APIRequestComponent()
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_curl(api_request):
|
|
||||||
# Arrange
|
|
||||||
field_value = (
|
|
||||||
"curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'"
|
|
||||||
)
|
|
||||||
build_config = {
|
|
||||||
"method": {"value": ""},
|
|
||||||
"urls": {"value": []},
|
|
||||||
"headers": {},
|
|
||||||
"body": {},
|
|
||||||
}
|
|
||||||
# Act
|
|
||||||
new_build_config = api_request.parse_curl(field_value, build_config.copy())
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert new_build_config["method"]["value"] == "GET"
|
|
||||||
assert new_build_config["urls"]["value"] == ["https://example.com/api/test"]
|
|
||||||
assert new_build_config["headers"]["value"] == {"Content-Type": "application/json"}
|
|
||||||
assert new_build_config["body"]["value"] == {"key": "value"}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue