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
36 changes: 33 additions & 3 deletions backend/src/baserow/contrib/integrations/core/service_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import csv
import io
import json
import re
import socket
import uuid
from datetime import datetime
Expand Down Expand Up @@ -57,6 +58,7 @@
)
from baserow.contrib.integrations.core.utils import calculate_next_periodic_run
from baserow.contrib.integrations.utils import get_http_request_function
from baserow.core.formula.registries import formula_runtime_function_registry
from baserow.core.formula.types import BaserowFormulaObject
from baserow.core.formula.validator import (
ensure_array,
Expand All @@ -83,6 +85,12 @@
from baserow.core.services.types import DispatchResult, FormulaToResolve, ServiceDict
from baserow.version import VERSION as BASEROW_VERSION

# Captures potential runtime formula function calls, e.g. `get(`, `concat(`, etc.
# The captured name is checked against the runtime function registry so that
# literal text like `foo(` (which isn't a real function) doesn't match. Function
# names are case-insensitive, so the name is lowercased before the lookup.
RE_FORMULA_FUNCTION = re.compile(r"\b([a-zA-Z_]+)\s*\(")


class CoreServiceType(ServiceType):
"""
Expand Down Expand Up @@ -531,6 +539,23 @@ def formulas_to_resolve(

return formulas

def _body_has_formula(self, service: CoreHTTPRequestService) -> bool:
"""
Returns True if the JSON body is built from a formula that interpolates a
runtime value, e.g. a data source via `get(...)` or a function like `now()`.

This is used to customize the JSON validation error with a more specific
error message.
"""

formula = (service.body_content or {}).get("formula") or ""

registered_functions = set(formula_runtime_function_registry.get_types())
return any(
match.group(1).lower() in registered_functions
for match in RE_FORMULA_FUNCTION.finditer(formula)
)

def dispatch_data(
self,
service: CoreHTTPRequestService,
Expand All @@ -554,9 +579,14 @@ def dispatch_data(
json.loads(body_content, strict=False) if body_content else None
)
except json.JSONDecodeError as e:
raise ServiceImproperlyConfiguredDispatchException(
"The body is not a valid JSON"
) from e
message = "The body is not a valid JSON"
if self._body_has_formula(service):
message += (
". If the body includes a value from a formula, wrap "
"it with to_json() so it is correctly quoted and "
"escaped, e.g. concat('{\"value\": ', to_json(get('...')), '}')."
)
raise ServiceImproperlyConfiguredDispatchException(message) from e
elif service.body_type == BODY_TYPE.FORM: # Form multipart payload
body_dict["data"] = {
f.key: resolved_values[f"form_data_{f.id}"]
Expand Down
4 changes: 4 additions & 0 deletions backend/src/baserow/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def ready(self):
RuntimeDivide,
RuntimeDurationFormat,
RuntimeEqual,
RuntimeFromJson,
RuntimeGenerateUUID,
RuntimeGet,
RuntimeGetProperty,
Expand Down Expand Up @@ -88,6 +89,7 @@ def ready(self):
RuntimeToDatetime,
RuntimeToday,
RuntimeToDuration,
RuntimeToJson,
RuntimeUpper,
RuntimeYear,
)
Expand Down Expand Up @@ -141,6 +143,8 @@ def ready(self):
formula_runtime_function_registry.register(RuntimeAt())
formula_runtime_function_registry.register(RuntimeToArray())
formula_runtime_function_registry.register(RuntimeRange())
formula_runtime_function_registry.register(RuntimeToJson())
formula_runtime_function_registry.register(RuntimeFromJson())
formula_runtime_function_registry.register(RuntimeNull())
formula_runtime_function_registry.register(RuntimeNumberFormat())
formula_runtime_function_registry.register(RuntimeToDuration())
Expand Down
40 changes: 39 additions & 1 deletion backend/src/baserow/core/formula/runtime_formula_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import random
import uuid
from datetime import datetime, timedelta
Expand Down Expand Up @@ -33,7 +34,13 @@
from baserow.core.formula.registries import RuntimeFormulaFunction
from baserow.core.formula.types import FormulaArg, FormulaArgs, FormulaContext
from baserow.core.formula.utils.date import convert_date_format_moment_to_python
from baserow.core.formula.validator import ensure_array, ensure_datetime, ensure_string
from baserow.core.formula.validator import (
ensure_array,
ensure_datetime,
ensure_deserialized_json,
ensure_json_serializable,
ensure_string,
)
from baserow.core.utils import to_path


Expand Down Expand Up @@ -784,6 +791,37 @@ def execute(self, context: FormulaContext, args: FormulaArgs):
return list(result)


class RuntimeToJson(RuntimeFormulaFunction):
type = "to_json"

args = [AnyBaserowRuntimeFormulaArgumentType()]

def execute(self, context: FormulaContext, args: FormulaArgs):
try:
serializable = ensure_json_serializable(args[0])
except ValidationError as exc:
raise BaserowFormulaSyntaxError(
"The 'to_json' function received a value that cannot be "
"converted to JSON."
) from exc
# Compact separators are used to match the frontend's `JSON.stringify` output.
return json.dumps(serializable, separators=(",", ":"))


class RuntimeFromJson(RuntimeFormulaFunction):
type = "from_json"

args = [TextBaserowRuntimeFormulaArgumentType()]

def execute(self, context: FormulaContext, args: FormulaArgs):
# Parse a JSON-encoded string back into a value. Returns `None` when the
# input isn't valid JSON.
try:
return ensure_deserialized_json(args[0], strict=True)
except ValidationError:
return None


class RuntimeNull(RuntimeFormulaFunction):
type = "null"

Expand Down
14 changes: 11 additions & 3 deletions backend/src/baserow/core/formula/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,28 @@ def default(self, obj):
return super().default(obj)


def ensure_deserialized_json(value: Any) -> Any:
def ensure_deserialized_json(value: Any, strict: bool = False) -> Any:
"""
Decode a JSON string if possible, otherwise return the value unchanged.

Values that are not strings are already deserialized and are returned as-is,
even when `strict` is True.

:param value: The value to decode if it is a valid JSON string.
:param strict: If True, raise a ValidationError when `value` is a string that
cannot be decoded as JSON, instead of returning it unchanged.
:return: The decoded JSON value if `value` is a valid JSON string, otherwise
`value`.
:raises ValidationError: If `strict` is True and `value` is a string that is
not valid JSON.
"""

if isinstance(value, str):
try:
return json.loads(value)
except (TypeError, JSONDecodeError):
pass
except (TypeError, JSONDecodeError) as exc:
if strict:
raise ValidationError("Value is not valid JSON.") from exc

return value

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

from baserow.contrib.integrations.core.models import BODY_TYPE, HTTP_METHOD
from baserow.contrib.integrations.core.service_types import CoreHTTPRequestServiceType
from baserow.core.services.exceptions import UnexpectedDispatchException
from baserow.core.services.exceptions import (
ServiceImproperlyConfiguredDispatchException,
UnexpectedDispatchException,
)
from baserow.core.services.handler import ServiceHandler
from baserow.test_utils.helpers import AnyInt, AnyStr
from baserow.test_utils.pytest_conftest import FakeDispatchContext
Expand Down Expand Up @@ -197,6 +200,106 @@ def test_core_http_request_basic_body_json(
)


@pytest.mark.django_db
def test_core_http_request_body_json_invalid_static_body(data_fixture):
"""
A static body that isn't valid JSON should fail with the plain error
message, without the extra hint.
"""

service = data_fixture.create_core_http_request_service(
url="'http://example.notexist/'",
# `{"value1": }` is missing a value, so it's invalid JSON.
body_content="""'{"value1": }'""",
body_type=BODY_TYPE.JSON,
)
service_type = service.get_type()

with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc:
service_type.dispatch(service, FakeDispatchContext())

assert str(exc.value) == "The body is not a valid JSON"


@pytest.mark.django_db
def test_core_http_request_body_json_invalid_with_data_provider_hint(data_fixture):
"""
When the body interpolates a formula value without `to_json(...)` and
the result isn't valid JSON, the error should hint at wrapping the value.
"""

service = data_fixture.create_core_http_request_service(
url="'http://example.notexist/'",
# The value isn't wrapped in quotes/`to_json`, so a string value
# produces invalid JSON, e.g. `{"value1": hello}`.
body_content="""concat('{"value1": ', get('page_parameter.id'), '}')""",
body_type=BODY_TYPE.JSON,
)
service_type = service.get_type()
dispatch_context = FakeDispatchContext(context={"page_parameter": {"id": "hello"}})

with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc:
service_type.dispatch(service, dispatch_context)

assert "The body is not a valid JSON" in str(exc.value)
assert "to_json(" in str(exc.value)


@pytest.mark.django_db
def test_core_http_request_body_json_invalid_with_formula_function_hint(data_fixture):
"""
The hint should also be shown for formula functions as well (e.g. `now()`).
"""

service = data_fixture.create_core_http_request_service(
url="'http://example.notexist/'",
# `now()` produces an unquoted value, e.g. `{"id": 2026-06-22 03:52:07.130000+00:00}`.
body_content="""concat('{"id": ', now(), '}')""",
body_type=BODY_TYPE.JSON,
)
service_type = service.get_type()

with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc:
service_type.dispatch(service, FakeDispatchContext())

assert "The body is not a valid JSON" in str(exc.value)
assert "wrap it with to_json()" in str(exc.value)


@pytest.mark.django_db
def test_core_http_request_body_json_with_to_json_escapes_data_source(data_fixture):
"""
Wrapping a data source value with `to_json(...)` produces a valid JSON body
even when the value contains characters that would otherwise break it.
"""

service = data_fixture.create_core_http_request_service(
url="'http://example.notexist/'",
body_content=(
"""concat('{"value1": ', to_json(get('page_parameter.id')), '}')"""
),
body_type=BODY_TYPE.JSON,
)
service_type = service.get_type()
dispatch_context = FakeDispatchContext(
context={"page_parameter": {"id": 'foo "bar"'}}
)

with mock_advocate_request({"foo": "bar"}) as mock_request:
service_type.dispatch(service, dispatch_context)

mock_request.assert_called_once_with(
**{
"headers": {"user-agent": AnyStr()},
"json": {"value1": 'foo "bar"'},
"method": HTTP_METHOD.GET,
"params": {},
"timeout": 30,
"url": "http://example.notexist/",
}
)


@pytest.mark.django_db
@pytest.mark.parametrize("control_char", ["\n", "\t", "\r"])
def test_core_http_request_basic_body_json_with_control_characters(
Expand Down
Loading
Loading