Travis Ennis

This article is intended to layout the method we use to accomplish de/serialization and validation of our incoming data to our REST API. In our setup we rely on two NPM libraries: json2typescript for de/serialization and class-validator for our validation. Let's dive in to how these get used in our code base.

Let's say we have an endpoint that allows us to create a user:

POST /users

And we can POST the following JSON to that endpoint:

{
    firstName: "John",
    lastName: "Doe",
    email: "jdoe@example.com",
}

So, if we wanted to validate that data we might write some code that looks like this:

function validate(data) {
    if(data.firstName && data.lastName && data.email) {
        return;
    }
    throw new Error("invalid");
}

With a much larger object, with more fields, this kind of validation would get out of hand quickly and all we have done here is make sure the data is present. We haven't validated it for correct types or ensured that email is even a valid email address. And we see this issue in our own code where we still validate data in this manner and we constantly struggle with our functions exceeding the cyclometric complexity limits we have set for our code base.

It would be much cleaner if could be more declarative about our validation rules and our two libraries above give us that ability. For example, to provide reules for the data above, we could write the following:

@JsonObject("User")
class User {
    @JsonProperty("firstName", String)
    @Validator.IsString()    
    name: firstName = undefined;

    @JsonProperty("lastName", String)
    @Validator.IsString()     
    lastName: string = undefined;

    @JsonProperty("email", String)
    @Validator.IsEmail()     
    email: string = undefined;        
}

There is quite a bit going on here, but it's pretty straightforward. The annotations JsonObject and JsonProperty are provided by json2typescript and provide the rules that allow us to convert data in JSON format to a Typescript class. The field names and types are declared. The annotation Validator is provided by class-validator and it provides us with a whole assortment of validation rules that we can use on our data. In this case, we tell the validator that firstName and lastName are strings and email is an email address adn that all three fields are required.

Now that the data is defined we can use it in the body of our request. Next we will create a model that represents the REST request itself:

@JsonObject("CreateUser")
class CreateUser {
    @JsonProperty("body", User)
    @Validator.ValidateNested()
    body: User = undefined;
}

In this case, we define that the body of the request is JSON that corresponds to the User model defined above and the rules here tell json2typescript to use the User model to deserialize the nested data and @Validator.ValidateNested() tells class-validator to apply the validation rules defined on User itself.

How do we use this in our code? First, CreateUser must extend IGetModelDescriptionRequest which establishes some base fields that our endpoints expect and then we define our endpoint in our code like so, assuming the server variable is a reference to, in our case, Restify:

server.post("/users", commonHandler({ serviceId: globals.getServiceId(), dataModel: CreateUser }), UserController.createUser);

The middleware handler, commonHandler allows us to define the data model this endpoint accepts and and it calls a function called validateRequest that knows to deserialize and the validate the incoming request. If everything passes, then the request continues on to the controller. If it fails, then this step would throw a 400 error indicating there was a bad request encountered because the data did not pass validation.

Pretty straightforward, but we can do more. Let's say that User has an optional field such as middleName. Then the model would look like so:

@JsonObject("User")
class User {
    @JsonProperty("firstName", String)
    @Validator.IsString()    
    name: firstName = undefined;

    @JsonProperty("middleName", String, true) // true is added  here to indicate the field is optional
    @Validate.IsOptional() // and the validator is told as well
    @Validator.IsString()     
    middleName?: string = undefined; // even let typescript know the field is optional

    @JsonProperty("lastName", String)
    @Validator.IsString()     
    lastName: string = undefined;

    @JsonProperty("email", String)
    @Validator.IsEmail()     
    email: string = undefined;        
}

Or let's say we can optionally assign the user to a list of groups that we have defined elsewhere as an Enum:

@JsonObject("User")
class User {
    @JsonProperty("firstName", String)
    @Validator.IsString()    
    name: firstName = undefined;

    @JsonProperty("middleName", String, true) // true is added  here to indicate the field is optional
    @Validate.IsOptional() // and the validator is told as well
    @Validator.IsString()     
    middleName?: string = undefined; // even let typescript know the field is optional

    @JsonProperty("lastName", String)
    @Validator.IsString()     
    lastName: string = undefined;

    @JsonProperty("email", String)
    @Validator.IsEmail()     
    email: string = undefined;        

    @JsonProperty("groups", [String], true)
    @Validator.IsOptional()
    @Validator.IsArray()
    @Validator.IsEnum(Group, { each: true }) // each indicates this rule should apply to each element in the array
    groups?: Group[] = undefined;         
}

Or let's say we want to give the endpoint the ability to allow an admin user to create a user in a different organization by passing the orgID as a query string parameter.

POST /users?orgID=testorg

Now CreateUser would look like:

@JsonObject("CreateUser")
class CreateUser {
    @JsonProperty("orgID", String, true)
    @Validator.IsOptional()
    @Validator.IsString()
    orgID?: string = undefined

    @JsonProperty("body", User)
    @Validator.ValidateNested()
    body: User = undefined;
}

We can do this both with query string parameters as well as path paramters allowing all aspects of the REST request to be represented in the request model.

Hopefully this prief introduction shows that it is much easier to delcare the rules for de/serliazation and validation using this method than it is to have to write custom validation code for every endpoint. And the library _class_validator_ provides us with a large set of predefined validators as well as the ability to create custom validators, which I will touch upon in another article.

Sources

You've found yourself on the site of Travis Ennis, a software engineer who lives in Indiana. If you'd like, you can contact me.