Skip to content

Commit

Permalink
feat: Get json editor validation working
Browse files Browse the repository at this point in the history
  • Loading branch information
daryllimyt committed Jun 1, 2024
1 parent be15ac0 commit e7599ac
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 12 deletions.
46 changes: 41 additions & 5 deletions frontend/src/components/workspace/panel/action/udf-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@ import {
import { FieldValues, FormProvider, useForm } from "react-hook-form"

import { PanelAction, useActionInputs } from "@/lib/hooks"
import { useUDFSchema } from "@/lib/udf"
import { ErrorDetails, useUDFSchema, validateUDFArgs } from "@/lib/udf"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
FormControl,
Expand All @@ -41,6 +40,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { toast } from "@/components/ui/use-toast"
import { getIcon } from "@/components/icons"
import { JSONSchemaTable } from "@/components/jsonschema-table"
import { CenteredSpinner } from "@/components/loading/spinner"
Expand Down Expand Up @@ -69,6 +69,9 @@ export function UDFActionForm({
},
})
const { actionInputs, setActionInputs } = useActionInputs(action)
const [JSONViewerrors, setJSONViewErrors] = React.useState<
ErrorDetails[] | undefined
>(undefined)

if (schemaLoading || actionLoading) {
return <CenteredSpinner />
Expand All @@ -92,6 +95,15 @@ export function UDFActionForm({
}

const onSubmit = async (values: FieldValues) => {
const validateResponse = await validateUDFArgs(udf.key, actionInputs)
if (!validateResponse.ok) {
const detail = validateResponse.detail
console.log("Validation failed", validateResponse)
setJSONViewErrors(detail ?? undefined)
return
}
setJSONViewErrors([])

const params = {
title: values.title,
description: values.description,
Expand All @@ -112,14 +124,14 @@ export function UDFActionForm({
<div className="col-span-2 overflow-hidden">
<h3 className="p-4 px-4">
<div className="flex w-full items-center space-x-4">
<Avatar>{getIcon(udf.key, { className: "size-5" })}</Avatar>
{getIcon(udf.key, { className: "size-6" })}
<div className="flex w-full flex-1 justify-between space-x-12">
<div className="flex flex-col">
<div className="flex w-full items-center justify-between text-xs font-medium leading-none">
<div className="flex w-full">{}</div>
<div className="flex w-full">{action.title}</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{"Description or subtitle"}
{action.description}
</p>
</div>
</div>
Expand Down Expand Up @@ -215,6 +227,7 @@ export function UDFActionForm({
<ActionInputs
inputs={actionInputs}
setInputs={setActionInputs}
errors={JSONViewerrors}
/>
</div>
</AccordionContent>
Expand All @@ -229,9 +242,11 @@ export function UDFActionForm({
export function ActionInputs({
inputs,
setInputs,
errors,
}: {
inputs: any
setInputs: (obj: any) => void
errors?: ErrorDetails[]
}) {
return (
<Tabs defaultValue="json">
Expand Down Expand Up @@ -262,6 +277,27 @@ export function ActionInputs({
className="text-xs"
/>
</div>
<div className="w-full space-y-2 py-4">
{errors?.length == 0 ? (
<AlertNotification
className="text-xs"
level="success"
message="Validated successfully!"
/>
) : (
errors?.map((error, idx) => {
const msg = `${error.type}: ${error.msg} @ \`${error.loc[0]}\`. Received input ${error.input}`
return (
<AlertNotification
className="text-xs"
key={idx}
level="error"
message={msg}
/>
)
})
)}
</div>
</TabsContent>
<TabsContent value="form">
<div className="justify-center space-y-4 text-center text-xs italic text-muted-foreground">
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/lib/udf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,42 @@ export function useUDFSchema(
}
return { udf, isLoading }
}

/**
* This is mirrored from pydantic_core.ErrorDetails
*/
const ErrorDetailsSchema = z.object({
type: z.string(),
loc: z.array(z.union([z.string(), z.number()])),
msg: z.string(),
input: z.unknown(),
ctx: z.record(z.string(), z.unknown()).nullish(),
})
export type ErrorDetails = z.infer<typeof ErrorDetailsSchema>

const UDFArgsValidationResponseSchema = z.object({
ok: z.boolean(),
message: z.string(),
detail: z.array(ErrorDetailsSchema).nullable(),
})
export type UDFArgsValidationResponse = z.infer<
typeof UDFArgsValidationResponseSchema
>

/**
*
* @param key
* @returns Hook that has the UDF schema that will be passed into AJV
*/
export async function validateUDFArgs(
key: string,
args: Record<string, unknown>
): Promise<UDFArgsValidationResponse> {
const response = await client.post<UDFArgsValidationResponse>(
`/udfs/${key}/validate`,
args
)
const res = await UDFArgsValidationResponseSchema.parseAsync(response.data)
console.log("validateUDFArgs", res)
return res
}
14 changes: 9 additions & 5 deletions tracecat/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi.params import Body
from fastapi.responses import ORJSONResponse, StreamingResponse
from loguru import logger
from pydantic_core import ValidationError
from sqlalchemy import Engine, or_
from sqlalchemy.exc import NoResultFound
from sqlmodel import Session, select
Expand Down Expand Up @@ -46,7 +47,7 @@
# lots of repetition and inconsistency
from tracecat.dsl.dispatcher import dispatch_workflow
from tracecat.middleware import RequestLoggingMiddleware
from tracecat.registry import RegistryValidationError, registry
from tracecat.registry import registry
from tracecat.types.api import (
ActionMetadataResponse,
ActionResponse,
Expand All @@ -70,6 +71,7 @@
StartWorkflowParams,
StartWorkflowResponse,
TriggerWorkflowRunParams,
UDFArgsValidationResponse,
UpdateActionParams,
UpdateSecretParams,
UpdateUserParams,
Expand Down Expand Up @@ -1803,7 +1805,7 @@ def validate_udf_args(
role: Annotated[Role, Depends(authenticate_user)],
key: str,
args: dict[str, Any],
) -> bool:
) -> UDFArgsValidationResponse:
"""Get an UDF spec by its path."""
try:
udf = registry.get(key)
Expand All @@ -1813,10 +1815,12 @@ def validate_udf_args(
) from e
try:
udf.validate_args(**args)
return True
except RegistryValidationError as e:
return UDFArgsValidationResponse(ok=True, message="UDF args are valid")
except ValidationError as e:
logger.opt(exception=e).error("Error validating UDF args")
return False
return UDFArgsValidationResponse(
ok=False, message="Error validating UDF args", detail=e.errors()
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand Down
12 changes: 10 additions & 2 deletions tracecat/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from loguru import logger
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
from pydantic_core import ValidationError
from typing_extensions import Doc

from tracecat import templates
Expand Down Expand Up @@ -68,9 +69,12 @@ def validate_args(self, *args, **kwargs) -> None:
# so template expressions will pass args model validation
try:
self.args_cls.model_validate(kwargs, strict=True)
except ValidationError as e:
logger.error(f"Validation error for UDF {self.key!r}. {e.errors()!r}")
raise e
except Exception as e:
raise RegistryValidationError(
f"Invalid input arguments for UDF {self.key!r}. {e}"
f"Error when validating input arguments for UDF {self.key!r}. {e}"
) from e


Expand Down Expand Up @@ -268,7 +272,11 @@ def _generate_model_from_function(
field_info = Field(default=default, **field_info_kwargs)
fields[name] = (field_type, field_info)
# Dynamically create and return the Pydantic model class
input_model = create_model(_udf_slug_camelcase(func, namespace), **fields) # type: ignore
input_model = create_model(
_udf_slug_camelcase(func, namespace),
__config__=ConfigDict(extra="forbid"),
**fields,
) # type: ignore
# Capture the return type of the function
rtype = sig.return_annotation if sig.return_annotation is not sig.empty else Any
rtype_adapter = TypeAdapter(rtype)
Expand Down
6 changes: 6 additions & 0 deletions tracecat/types/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,9 @@ class CaseEventParams(BaseModel):

class UpsertWorkflowDefinitionParams(BaseModel):
content: DSLInput


class UDFArgsValidationResponse(BaseModel):
ok: bool
message: str
detail: Any | None = None

0 comments on commit e7599ac

Please sign in to comment.