mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
fix: support object and array parameters in custom HTTP tools (#373)
* fix: support object and array parameters in custom HTTP tools * feat(ui): expose object and array types in the custom tool parameter editor * fix: error handling and schema generation --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
98d2b24cba
commit
dd85c4a1b4
18 changed files with 308 additions and 67 deletions
|
|
@ -19,7 +19,7 @@ from api.enums import ToolCategory
|
|||
DEFAULT_MCP_TIMEOUT_SECS = 30
|
||||
DEFAULT_MCP_SSE_READ_TIMEOUT_SECS = 300
|
||||
|
||||
ToolParameterType = Literal["string", "number", "boolean"]
|
||||
ToolParameterType = Literal["string", "number", "boolean", "object", "array"]
|
||||
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
||||
ToolCategoryValue = Literal[
|
||||
"http_api",
|
||||
|
|
@ -47,7 +47,9 @@ class ToolParameter(BaseModel):
|
|||
)
|
||||
type: ToolParameterType = Field(
|
||||
description="JSON type for the parameter value.",
|
||||
json_schema_extra=_llm_hint("Allowed values are string, number, and boolean."),
|
||||
json_schema_extra=_llm_hint(
|
||||
"Allowed values are string, number, boolean, object, and array."
|
||||
),
|
||||
)
|
||||
description: str = Field(
|
||||
description="Description shown to the model for this parameter.",
|
||||
|
|
@ -65,7 +67,12 @@ class PresetToolParameter(BaseModel):
|
|||
"""A parameter injected by Dograh at runtime."""
|
||||
|
||||
name: str = Field(description="Parameter name used as a key in the request body.")
|
||||
type: ToolParameterType = Field(description="JSON type for the resolved value.")
|
||||
type: ToolParameterType = Field(
|
||||
description="JSON type for the resolved value.",
|
||||
json_schema_extra=_llm_hint(
|
||||
"Allowed values are string, number, boolean, object, and array."
|
||||
),
|
||||
)
|
||||
value_template: str = Field(
|
||||
description="Fixed value or template, e.g. {{initial_context.phone_number}}.",
|
||||
json_schema_extra=_llm_hint(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from .google import (
|
|||
from .sarvam import (
|
||||
SARVAM_LANGUAGES,
|
||||
SARVAM_LLM_MODELS,
|
||||
SARVAM_STT_LANGUAGES_V25,
|
||||
SARVAM_STT_LANGUAGES_V3,
|
||||
SARVAM_STT_LANGUAGES_V25,
|
||||
SARVAM_STT_MODELS,
|
||||
SARVAM_TTS_MODELS,
|
||||
SARVAM_V2_VOICES,
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ from api.services.configuration.options import (
|
|||
GOOGLE_VERTEX_REALTIME_VOICES,
|
||||
SARVAM_LANGUAGES,
|
||||
SARVAM_LLM_MODELS,
|
||||
SARVAM_STT_LANGUAGES_V25,
|
||||
SARVAM_STT_LANGUAGES_V3,
|
||||
SARVAM_STT_LANGUAGES_V25,
|
||||
SARVAM_STT_MODELS,
|
||||
SARVAM_TTS_MODELS,
|
||||
SARVAM_V2_VOICES,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ TYPE_MAP = {
|
|||
"string": "string",
|
||||
"number": "number",
|
||||
"boolean": "boolean",
|
||||
"object": "object",
|
||||
"array": "array",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -45,10 +47,24 @@ def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
|
|||
if not param_name:
|
||||
continue
|
||||
|
||||
properties[param_name] = {
|
||||
"type": TYPE_MAP.get(param_type, "string"),
|
||||
"description": param_desc,
|
||||
}
|
||||
schema_type = TYPE_MAP.get(param_type, "string")
|
||||
if schema_type == "object":
|
||||
properties[param_name] = {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": param_desc,
|
||||
}
|
||||
elif schema_type == "array":
|
||||
properties[param_name] = {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"description": param_desc,
|
||||
}
|
||||
else:
|
||||
properties[param_name] = {
|
||||
"type": schema_type,
|
||||
"description": param_desc,
|
||||
}
|
||||
|
||||
if param_required:
|
||||
required.append(param_name)
|
||||
|
|
@ -127,6 +143,26 @@ def _coerce_parameter_value(value: Any, param_type: str) -> Any:
|
|||
|
||||
raise ValueError(f"Cannot convert '{value}' to boolean")
|
||||
|
||||
if param_type == "object":
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Cannot convert '{value}' to object") from exc
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
raise ValueError(f"Cannot convert '{value}' to object")
|
||||
|
||||
if param_type == "array":
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Cannot convert '{value}' to array") from exc
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
raise ValueError(f"Cannot convert '{value}' to array")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from pipecat.services.llm_service import FunctionCallParams
|
|||
|
||||
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
|
||||
from api.services.workflow.tools.custom_tool import (
|
||||
_coerce_parameter_value,
|
||||
execute_http_tool,
|
||||
tool_to_function_schema,
|
||||
)
|
||||
|
|
@ -141,6 +142,51 @@ class TestToolToFunctionSchema:
|
|||
assert "duration_minutes" in required
|
||||
assert "is_priority" not in required
|
||||
|
||||
def test_tool_with_object_and_array_parameters(self):
|
||||
"""Test converting a tool with object and array parameters."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="test-uuid-nested",
|
||||
name="Create Booking",
|
||||
description="Create a booking with nested details",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/bookings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "booking",
|
||||
"type": "object",
|
||||
"description": "Nested booking payload",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "attendees",
|
||||
"type": "array",
|
||||
"description": "Booking attendees",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
schema = tool_to_function_schema(tool)
|
||||
|
||||
props = schema["function"]["parameters"]["properties"]
|
||||
assert props["booking"] == {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": "Nested booking payload",
|
||||
}
|
||||
assert props["attendees"] == {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"description": "Booking attendees",
|
||||
}
|
||||
|
||||
def test_preset_parameters_are_not_exposed_to_llm_schema(self):
|
||||
"""Test that preset parameters are injected at runtime, not shown to the LLM."""
|
||||
tool = MockToolModel(
|
||||
|
|
@ -295,6 +341,51 @@ class TestExecuteHttpTool:
|
|||
assert result["status_code"] == 201
|
||||
assert result["data"]["id"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_sends_nested_json_body(self):
|
||||
"""Test that POST requests preserve nested arguments in the JSON body."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="test-uuid-nested",
|
||||
name="Create Booking",
|
||||
description="Create a nested booking",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/bookings",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"booking": {
|
||||
"start": "2026-05-28T10:00:00Z",
|
||||
"attendee": {"name": "Jane", "email": "jane@example.com"},
|
||||
"metadata": {"source": "voice"},
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
|
||||
) as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"bookingId": "booking-123"}
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
result = await execute_http_tool(tool, arguments)
|
||||
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs["json"] == arguments
|
||||
assert isinstance(call_kwargs["json"]["booking"], dict)
|
||||
assert isinstance(call_kwargs["json"]["booking"]["attendee"], dict)
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_injects_preset_parameters(self):
|
||||
"""Test that preset parameters are resolved from runtime context."""
|
||||
|
|
@ -640,6 +731,51 @@ class TestExecuteHttpTool:
|
|||
mock_db.get_credential_by_uuid.assert_not_called()
|
||||
|
||||
|
||||
class TestCoerceParameterValue:
|
||||
"""Tests for _coerce_parameter_value function."""
|
||||
|
||||
def test_object_value_returns_dict_unchanged(self):
|
||||
"""Test that object parameters preserve dict values."""
|
||||
value = {"attendee": {"name": "Jane"}}
|
||||
|
||||
assert _coerce_parameter_value(value, "object") is value
|
||||
|
||||
def test_object_value_parses_json_string(self):
|
||||
"""Test that object parameters parse JSON string values."""
|
||||
value = '{"attendee": {"name": "Jane"}}'
|
||||
|
||||
assert _coerce_parameter_value(value, "object") == {
|
||||
"attendee": {"name": "Jane"}
|
||||
}
|
||||
|
||||
def test_array_value_returns_list_unchanged(self):
|
||||
"""Test that array parameters preserve list values."""
|
||||
value = [{"name": "Jane"}, {"name": "Sam"}]
|
||||
|
||||
assert _coerce_parameter_value(value, "array") is value
|
||||
|
||||
def test_array_value_parses_json_string(self):
|
||||
"""Test that array parameters parse JSON string values."""
|
||||
value = '[{"name": "Jane"}, {"name": "Sam"}]'
|
||||
|
||||
assert _coerce_parameter_value(value, "array") == [
|
||||
{"name": "Jane"},
|
||||
{"name": "Sam"},
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("value", ["not json", "[]", "null"])
|
||||
def test_object_value_rejects_invalid_or_wrong_shape(self, value):
|
||||
"""Test that object parameters require a JSON object."""
|
||||
with pytest.raises(ValueError, match="Cannot convert"):
|
||||
_coerce_parameter_value(value, "object")
|
||||
|
||||
@pytest.mark.parametrize("value", ["not json", "{}", "null"])
|
||||
def test_array_value_rejects_invalid_or_wrong_shape(self, value):
|
||||
"""Test that array parameters require a JSON array."""
|
||||
with pytest.raises(ValueError, match="Cannot convert"):
|
||||
_coerce_parameter_value(value, "array")
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
"""Tests for auth header building utilities."""
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,53 @@ def test_update_tool_request_accepts_mcp_definition():
|
|||
assert req.definition.config.url == "https://x/mcp"
|
||||
|
||||
|
||||
def test_update_tool_request_accepts_http_api_complex_parameter_types():
|
||||
"""HTTP API tools may accept structured JSON parameters."""
|
||||
req = UpdateToolRequest(
|
||||
name="Check Availability New Multi",
|
||||
description="Check Availability when asked for it.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://automation.dograh.com/webhook/example",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "params",
|
||||
"type": "object",
|
||||
"description": (
|
||||
"An object containing the name and datetime in ISO format"
|
||||
),
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "slots",
|
||||
"type": "array",
|
||||
"description": "Candidate availability slots.",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
"preset_parameters": [
|
||||
{
|
||||
"name": "phone_number",
|
||||
"type": "string",
|
||||
"value_template": "{{initial_context.phone_number}}",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
"timeout_ms": 5000,
|
||||
"customMessageType": "text",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert req.definition.type == "http_api"
|
||||
parameters = req.definition.config.parameters
|
||||
assert parameters[0].type == "object"
|
||||
assert parameters[1].type == "array"
|
||||
|
||||
|
||||
def test_create_tool_request_accepts_mcp_with_all_fields():
|
||||
"""All optional MCP config fields are accepted and preserved."""
|
||||
req = CreateToolRequest(
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-DuffQq.json
|
||||
# timestamp: 2026-05-31T11:41:57+00:00
|
||||
# filename: dograh-openapi-rs5H7P.json
|
||||
# timestamp: 2026-06-02T06:01:29+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -337,6 +337,8 @@ class Type(Enum):
|
|||
string = 'string'
|
||||
number = 'number'
|
||||
boolean = 'boolean'
|
||||
object = 'object'
|
||||
array = 'array'
|
||||
|
||||
|
||||
class PresetToolParameter(BaseModel):
|
||||
|
|
@ -427,6 +429,8 @@ class Type1(Enum):
|
|||
string = 'string'
|
||||
number = 'number'
|
||||
boolean = 'boolean'
|
||||
object = 'object'
|
||||
array = 'array'
|
||||
|
||||
|
||||
class ToolParameter(BaseModel):
|
||||
|
|
|
|||
|
|
@ -775,7 +775,7 @@ export interface components {
|
|||
* @description JSON type for the resolved value.
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "string" | "number" | "boolean";
|
||||
type: "string" | "number" | "boolean" | "object" | "array";
|
||||
/**
|
||||
* Value Template
|
||||
* @description Fixed value or template, e.g. {{initial_context.phone_number}}.
|
||||
|
|
@ -933,7 +933,7 @@ export interface components {
|
|||
* @description JSON type for the parameter value.
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "string" | "number" | "boolean";
|
||||
type: "string" | "number" | "boolean" | "object" | "array";
|
||||
/**
|
||||
* Description
|
||||
* @description Description shown to the model for this parameter.
|
||||
|
|
|
|||
15
ui/AGENTS.md
15
ui/AGENTS.md
|
|
@ -71,6 +71,21 @@ useEffect(() => {
|
|||
|
||||
The auth interceptor (which attaches the Bearer token) is only registered once auth is fully loaded. Fetching before that sends unauthenticated requests that silently fail.
|
||||
|
||||
### API Error Handling
|
||||
|
||||
The generated client does **not** throw on HTTP error responses — it resolves to `{ data, error }`. A `try/catch` only catches network failures, so a 4xx/5xx slips through silently if you only check `response.data`. Always check `response.error`:
|
||||
|
||||
```tsx
|
||||
const response = await someApiCall({ ... });
|
||||
if (response.error) {
|
||||
setError(detailFromError(response.error, "Failed to save thing"));
|
||||
return;
|
||||
}
|
||||
// ...use response.data
|
||||
```
|
||||
|
||||
Use `detailFromError` from `@/lib/apiError` to turn the error into a string — never render `error.detail` directly. FastAPI returns `detail` as a string for `HTTPException` but as an **array** of `{ msg, loc, ... }` objects for 422 validation errors; passing that array to React (`{error}`) crashes the page with "Objects are not valid as a React child". The helper normalizes both shapes and takes an optional fallback message.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
|
||||
|
|
@ -449,14 +450,3 @@ export default function TelephonyConfigurationDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function TelephonyConfigurationsPage() {
|
||||
|
|
@ -331,14 +332,3 @@ export default function TelephonyConfigurationsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
|
|
@ -58,6 +59,8 @@ function normalizeParameterType(value: string | null | undefined): ParameterType
|
|||
switch (value) {
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "object":
|
||||
case "array":
|
||||
return value;
|
||||
default:
|
||||
return "string";
|
||||
|
|
@ -447,6 +450,11 @@ export default function ToolDetailPage() {
|
|||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setError(detailFromError(response.error, "Failed to save tool"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setTool(response.data);
|
||||
setSaveSuccess(true);
|
||||
|
|
|
|||
|
|
@ -2539,7 +2539,7 @@ export type PresetToolParameter = {
|
|||
*
|
||||
* JSON type for the resolved value.
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
/**
|
||||
* Value Template
|
||||
*
|
||||
|
|
@ -3625,7 +3625,7 @@ export type ToolParameter = {
|
|||
*
|
||||
* JSON type for the parameter value.
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
/**
|
||||
* Description
|
||||
*
|
||||
|
|
@ -4594,6 +4594,12 @@ export type WorkflowRunResponseSchema = {
|
|||
cost_info: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Usage Info
|
||||
*/
|
||||
usage_info?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Definition Id
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import type { ToolParameter as ApiToolParameter } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -14,7 +15,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export type ParameterType = "string" | "number" | "boolean";
|
||||
export type ParameterType = ApiToolParameter["type"];
|
||||
|
||||
export interface ToolParameter {
|
||||
name: string;
|
||||
|
|
@ -124,6 +125,8 @@ export function ParameterEditor({
|
|||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
<SelectItem value="object">Object</SelectItem>
|
||||
<SelectItem value="array">Array</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -267,6 +270,8 @@ export function PresetParameterEditor({
|
|||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
<SelectItem value="object">Object</SelectItem>
|
||||
<SelectItem value="array">Array</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface ConfigFormDialogProps {
|
||||
|
|
@ -132,7 +133,7 @@ export function ConfigFormDialog({
|
|||
body: { name: name || undefined, config: configPayload },
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
if (res.error) throw new Error(detailFromError(res.error, "Failed to save configuration"));
|
||||
toast.success("Configuration updated");
|
||||
} else {
|
||||
const res = await createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost(
|
||||
|
|
@ -145,7 +146,7 @@ export function ConfigFormDialog({
|
|||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
if (res.error) throw new Error(detailFromError(res.error, "Failed to save configuration"));
|
||||
toast.success("Configuration created");
|
||||
}
|
||||
onOpenChange(false);
|
||||
|
|
@ -345,16 +346,3 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// FastAPI error responses come back as { detail: string } or
|
||||
// { detail: [{loc, msg, ...}] }. Surface a useful message either way.
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Failed to save configuration";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface PhoneNumberDialogProps {
|
||||
|
|
@ -146,7 +147,7 @@ export function PhoneNumberDialog({
|
|||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
if (res.error) throw new Error(detailFromError(res.error, "Failed to save phone number"));
|
||||
providerSync = res.data?.provider_sync;
|
||||
toast.success("Phone number updated");
|
||||
} else {
|
||||
|
|
@ -164,7 +165,7 @@ export function PhoneNumberDialog({
|
|||
},
|
||||
},
|
||||
);
|
||||
if (res.error) throw new Error(detailFromError(res.error));
|
||||
if (res.error) throw new Error(detailFromError(res.error, "Failed to save phone number"));
|
||||
providerSync = res.data?.provider_sync;
|
||||
toast.success("Phone number added");
|
||||
}
|
||||
|
|
@ -302,14 +303,3 @@ export function PhoneNumberDialog({
|
|||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function detailFromError(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return "Failed to save phone number";
|
||||
}
|
||||
|
|
|
|||
19
ui/src/lib/apiError.ts
Normal file
19
ui/src/lib/apiError.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Extract a human-readable message from a backend error response.
|
||||
*
|
||||
* The generated API client returns `{ error }` on failure (it does not throw),
|
||||
* and FastAPI shapes that error as either `{ detail: string }` (HTTPException)
|
||||
* or `{ detail: [{ msg, loc, ... }] }` (422 validation). This normalizes both
|
||||
* to a single string so it can be rendered or thrown directly — never pass the
|
||||
* raw `detail` to React, as the 422 array crashes rendering.
|
||||
*/
|
||||
export function detailFromError(err: unknown, fallback = "Request failed"): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue