Note: This post describes my developer experience, the step I have to take to make it work. I am not describing the final result but also the problems I have experienced and how I dealt with them. In the development process, the issues are snowballing and spiralling out of control.
When I have chosen a completely different programming language (TypeScript) and server-side API approach (GraphQL) and different React UI library (ant-design) for the development of my application, I did not know how it will slow me down. Every new feature I wanted to implement (including the most basic ones) resulted in spend some time researching using Google, StackOverflow and GitHub pages. This time it was no different – server-side validation and communicating the input validation failure to the user.
The form
The catalog object has two properties – validFrom
and validTo
– to make it valid only in this date range. None of these properties is required so that the catalog can be active from the specific day forward or till some date. Or forever. But the date range has to be valid date range and end of validity has to be after the start of validity.
The validator is implemented on the server-side using a custom validator and added to properties of the input type as @Validate(DateFromToValidator)
decorator.
@InputType()
export class CatalogInput {
@Field({ nullable: true })
id?: number;
@Field()
name: string;
@MaxLength(255)
@Field({ nullable: true })
description?: string;
@Validate(DateFromToValidator)
@Field({ nullable: true })
validFrom?: Date;
@Validate(DateFromToValidator)
@Field({ nullable: true })
validTo?: Date;
@Field()
isPublic: boolean;
@Field()
isEnabled: boolean;
}
The library is using class-validator and I can create a custom validator constraint implementing ValidatorConstraintInterface
interface. The interface has two methods
validate
that should returntrue
when everything is ok anddefaultMessage
to return the error message.
The code of the validation constraint (not reusable) to validate validFrom
and validTo
date range is as follows:
@ValidatorConstraint({ name: "dateFromToValidator", async: false })
export class DateFromToValidator implements ValidatorConstraintInterface {
validate(value: string, args: ValidationArguments) {
const catalog = args.object as CatalogInput;
if (!catalog.isEnabled) {
return true;
}
return catalog.validFrom <= catalog.validTo;
}
defaultMessage(args: ValidationArguments) {
return "Text is too short or too long!";
}
}
You can argue that I could have implemented the validation on the client. Still, the golden rule of API design is never to trust the values provided by the user and always validate the data on the server-side. While this may improve the UX, it also means to have two implementations of the validator.
You may also say that is not a problem since the code can work on both browser and server-side and you may be right. But for now, let’s leave like it is 🙂 I will change it in the future.
When the validation fails on server-side, GraphQL API returns a response with errors
array indicating a problem. The response has a 200 HTTP status code, unlike REST. The error response is well structured and contains some additional information like stack trace. The Apollo server introduced standardized errors where additional details are provided in the extensions
map 12.
{
"errors": [
{
"message": "Argument Validation Error!",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"updateCatalog"
],
"extensions": {
"code": "BAD_USER_INPUT",
"exception": {
"validationErrors": [
{
"target": {
"id": 2,
"name": "Name",
"description": "Description",
"validFrom": "2019-12-19T11:42:31.972Z",
"validTo": null,
"isPublic": false,
"isEnabled": true
},
"value": "2019-12-19T11:42:31.972Z",
"property": "dateFromToValidator",
"children": [],
"constraints": {
"catalogValidity": "Text is too short or too long!"
}
}
],
"stacktrace": [
"UserInputError: Argument Validation Error!",
" at Object.exports.translateError (C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:69:11)",
" at C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:29:13",
" at Generator.throw (<anonymous>)",
" at rejected (C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:6:65)",
" at process._tickCallback (internal/process/next_tick.js:68:7)"
]
}
}
}
],
"data": null
}
To display the validation errors in the ant-design form, I have to convert the GraphQL error response to an object that can be passed to setFields
method of the form. The signature of the method is setFields(obj: Object): void;
which is not very helpful. The search on ant-design GitHub pages showed that the object passed must have the same properties as the edited object. Each member is another object with a property value
with the edited object’ value and optional property errors
containing the error(s) to be displayed.
const formFields: { [property: string]: { value: any, errors?: Error[] } } = {};
The failed attempt to mutate the data throws an exception – the response is rejected in Apollo. The error handler is a switch with cases for possible error codes. Only user input errors are handled here (BAD_USER_INPUT
extension code).
try {
await this.props.client.mutate<CatalogData>({
mutation: UPDATE_CATALOG,
variables: {
catalog
}
});
} catch (e) {
const apolloError = e as ApolloError;
if (apolloError) {
apolloError.graphQLErrors.forEach(apolloError => {
const code = apolloError.extensions.code;
switch (code) {
case "BAD_USER_INPUT":
const validationErrors = apolloError.extensions.exception.validationErrors;
const formFields: { [property: string]: any } = {};
validationErrors.forEach(
(validationError: ApolloValidationError) => {
const {
target,
property,
constraints,
value
} = validationError;
const errors = [];
for (const key in constraints) {
const value = constraints[key];
errors.push(new Error(value));
}
formFields[property] = {
value,
errors
};
}
);
setTimeout(() => {
form.setFields(formFields);
}, 500);
break;
default:
this.handleError(e);
break;
}
});
}
}
And this object will passed into setFields
method:
{
"validFrom": {
"value": "2019-12-18T12:31:42.487Z",
"errors": [
Error("Text is too short or too long!")
]
},
}
This code does not work – the DatePicker
control expects a value of moment
type, and it gets string instead. This attempt ends in warnings being written to a console and an exception thrown:
Warning: Failed prop type: Invalid prop `value` of type `string` supplied to `CalendarMixinWrapper`, expected `object`.
Warning: Failed prop type: Invalid prop `value` supplied to `Picker`.
TypeError: value.format is not a function
When these input fields are rendered the value provided is explicitly converted from Date
to moment
instance:
const momentFunc = (value?: Date) => moment(value).isValid() ? moment(value) : null;
<Form.Item label="Platnost do">
<Row>
<Col>
{getFieldDecorator("validTo", { initialValue: momentFunc(catalog.validTo) })
(<DatePicker locale={locale} />)
}
</Col>
</Row>
</Form.Item>
The form is re-rendered, but the initialiValue
is not recalculated.
The quick and ugly hack is to convert the string values representing dates into moment
instances. It is ugly because I have to list the names of properties holding date values. Also, I can’t use this as a general solution:
const momentFunc = (value?: string) => moment(value).isValid() ? moment(value) : null;
let v = value;
if (key === "validTo" || key === "validFrom") {
v = momentFunc(value);
}
const errors = [];
for (const key in constraints) {
const value = constraints[key];
errors.push(new Error(value));
}
formFields[property] = { value: v, errors };
This works now with a minor problem – the Oops, I have been accidentally calling validFrom
input field is cleared on form submit and comes back with the validation failure message.form.resetFields
method in the submit handler.
Types exist only at compile time
Even though TypeScript brings optional static type-checking, it is only performed at compile time. The type information (the metadata) does not become a part of the compiled JavaScript code (unlike in C# or Java). However, the code elements can be extended with decorators to provide information in runtime. 3
Good news! I have already decorators in place – I have decorated the properties of CatalogInput
class. This type is used for updating (mutating) the catalog instance through the GraphQL API. Bad news – this class is located in the server project which is parallel to the client project 🙁 I will write a separate post on this once I figure out how to achieve code sharing between client and server.
For now the ugly hack is to try to convert given value to a moment
instance:
const isValidDate = (dateObject: string) => new Date(dateObject).toString() !== "Invalid Date";
let v = value;
if (isValidDate(v)) {
v = momentFunc(value);
}
const errors = [];
for (const key in constraints) {
const value = constraints[key];
errors.push(new Error(value));
}
formFields[property] = { value: v, errors };
A much better and robust solution would be to use the type information from the decorators on client-side or extend the GraphQL API response with type information (again, from member decorator on server-side).