Communicating server-side input validation failures with GraphQL and ant-design form

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 return true when everything is ok and
  • defaultMessage 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 validFrom input field is cleared on form submit and comes back with the validation failure message. Oops, I have been accidentally calling 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).

Leave a Reply

Your email address will not be published. Required fields are marked *

Blue Captcha Image
Refresh

*