JF's Dev Blog

Django, Vue, and other things, too

Use TypeScript to Synchronize Django REST Framework and Vue.js: Part 2

Two whale tails emerging from water

Photo by Steve Halama on Unsplash

(Un)structured Data

The idea of using API metadata in client code isn't revolutionary. AutoRest uses OpenAPI schemas to generate code for a number of languages, and Django REST Framework's docs suggest using metadata to jumpstart client libraries:

API schemas are a useful tool that allow for a range of use cases, including generating reference documentation, or driving dynamic client libraries that can interact with your API.
- https://www.django-rest-framework.org/api-guide/schemas/

Whereas the first part of this post series covered generating metadata to use in a JavaScript/TypeScript client, this post makes good on that promise and uses that user metadata in a Vue frontend.

How? Well, there are a number of ways to use API metadata to power a frontend, like:

  • Generating constants
  • Creating type-safe forms
  • Automatic form generation
  • Data class generation

This blog post will cover type-safe and (mostly) automatically-generated forms.

Type-safe forms from JSON metadata

Here's a simple Vue component containing a form to create a new user:

<template>
  <div id="app">
    <h1>User Form</h1>
    <form @submit.prevent="onSubmit">
      <label>First name</label>
      <input v-model="form.firstName">
      <label>Last name</label>
      <input v-model="form.lastName">
      <label>Email address</label>
      <input v-model="form.firstName">
      <label>Username</label>
      <input v-model="form.username">
      <button type="submit">Submit</button>
    </form>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';export default Vue.extend({
  name: 'App',  data() {
    return {
      form: {},
    }
  },

  methods: {
    onSubmit() {
      // Pretend a user is created
    },
  },
});
</script>

Notice anything wrong with that form?

Go ahead and take a good look.

At first it looks like a standard form, certainly. The more you look at it, however, the more you may realize how plain it is. It is unremarkable in whole and in part. There's so little to it that it could well pass for unobjectionable. After all, there isn't anything wrong with the form, really — but there isn't much right with it. Although Vue considers the above form to be correctly typed, we could add a number of useful type-related improvements:

  • Required fields
  • Typed inputs (like input="email")
  • A typed form object

Enter User metadata.

There are a number of ways to use the metadata specified in [User.json](https://gist.githubusercontent.com/johnfraney/d8fe9868948fdf7bdc599ff0a9c5cbcf/raw/5a32bd7c68e6eda0883241a1947b3d7d6e48c575/user_options.json) (described in detail in Part I of this series) to improve this form.

Check out this typed form, and then we'll look at how each metadata usage is an improvement over the untyped form above:

<template>
  <div id="app">
    <h1>User Form</h1>
    <form @submit.prevent="onSubmit">
      <div>
        <label v-text="userMetadata.first_name.label" />
        <input
          v-model="form[userMetadata.first_name.field_name]"
          :maxlength="userMetadata.first_name.max_length"
          :name="userMetadata.first_name.field_name"
          :required="userMetadata.first_name.required"
        >
      </div>
      <div>
        <label v-text="userMetadata.last_name.label" />
        <input
          v-model="form[userMetadata.last_name.field_name]"
          :maxlength="userMetadata.last_name.max_length"
          :name="userMetadata.last_name.field_name"
          :required="userMetadata.last_name.required"
        >
      </div>
      <div>
        <label v-text="userMetadata.email.label" />
        <input
          v-model="form[userMetadata.email.field_name]"
          :maxlength="userMetadata.email.max_length"
          :name="userMetadata.email.field_name"
          :required="userMetadata.email.required"
          :type="userMetadata.email.format"
        >
      </div>
      <div>
        <label v-text="userMetadata.username.label" />
        <input
          v-model="form[userMetadata.username.field_name]"
          :maxlength="userMetadata.username.max_length"
          :name="userMetadata.username.field_name"
          :required="userMetadata.username.required"
        >
      </div>
      <button type="submit">Submit</button>
    </form>
    <pre v-text="userMetadata" />
  </div>
</template>
<script lang="ts">
import Vue from 'vue'import User from  './metadata/User.json'export default Vue.extend({
  name: 'App',  data() {
    return {
      form: {},
      userMetadata: User,
    }
  },  methods: {
    onSubmit() {
      // Pretend a user is created
    },
  },
})
</script>

Most changes here are in the template. In the component itself, the only change is userMetadata, which is a typed object created from User.json. (To use JSON files in this way, ensure that your [tsconfig.json](https://www.staging-typescript.org/tsconfig#resolveJsonModule) has [resolveJSONModule: true](https://www.staging-typescript.org/tsconfig#resolveJsonModule).)

The template includes these changes, all of which help the form reflect the backend user model:

  • <label v-text>: Instead of specifying form labels in manually, they are pulled from the user metadata.
  • <input v-model>: Instead of writing a property accessor in the template, like form.firtsName, we can guarantee that the key for the user form is the same as the database field name, e.g. form[userMetadata.first_name.field_name]. Oh, and did you notice the fristName typo in that property accessor? Using the metadata for field names is more typo-proof.
  • <input type>: API metadata can specify which type of input should be used for each field, like email or number. This helps prevent validation errors.
  • Input validation attributes (required, maxlength): Speaking of preventing validation errors, database-level constraints for use fields are reflected in the JSON metadata, which we can use directly in <input> elements. User.json even includes pattern, so you can reuse regular expressions defined on the server in your client forms. This is a nice win, because writing and maintaining regular expressions isn't user-friendly.
  • <input name>: This is a small change, but it makes it easy to select specific inputs in unit tests.

Now that we've got a typed form, why not take things one step further? because our metadata tells us so much about the API endpoint's expectations, could we use that data to generate a form automatically?

Dear reader, we can.

Automatically-generated forms from JSON metadata

This is where having robust metadata really shines. With some tweaking, we can leverage our metadata as the basis for a fully-typed component with a small, maintainable template.

Take a look:

<template>
  <div id="app">
    <h1>User Form</h1>
    <form @submit.prevent="onSubmit">
      <div v-for="(inputData, inputName) in fields" :key="inputName">
        <label v-text="inputData.label" />
        <input
          v-model="form[inputName]"
          v-bind="inputData.inputAttributes"
        >
      </div>
      <button type="submit">Submit</button>
    </form>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';import User from  './metadata/User.json'type UserForm = {
  [UserField in keyof typeof User]?: typeof User[UserField]["initial"]
}

interface InputAttributes {
  [key: string]: boolean | number | string
}

interface InputData {
  inputAttributes: InputAttributes
  label: string
}

function convertFieldMetadataToInputData(fieldMetadata: typeof User[keyof typeof User]): InputData {
  const label = fieldMetadata.label
  const inputAttributes: InputAttributes = {
    name: fieldMetadata.field_name,
    required: fieldMetadata.required,
    type: fieldMetadata.type,
  }
  // Add non-universal properties
  if ('max_length' in fieldMetadata) {
    inputAttributes.maxlength = fieldMetadata.max_length
  }
  if ('pattern' in fieldMetadata) {
    inputAttributes.maxlength = fieldMetadata.pattern
  }
  return {
    inputAttributes,
    label,
  }
}

export default Vue.extend({
  name: 'App',  data() {
    return {
      fields: {
        [User.first_name.field_name]: convertFieldMetadataToInputData(User.first_name),
        [User.last_name.field_name]: convertFieldMetadataToInputData(User.last_name),
        [User.email.field_name]: convertFieldMetadataToInputData(User.email),
        [User.username.field_name]: convertFieldMetadataToInputData(User.username),
      },
      form: {},
    }
  },methods: {
    onSubmit() {
      // Pretend a user is created
    },
  },
});
</script>

Although this component is a few more lines than the one preceding it, much of this logic can be removed from this component and shared across every form in your application.

Quite a bit new is happening in the component, so let's take a look at the important changes.

First, the form body:

<div v-for="(inputData, inputName) in fields" :key="inputName">
  <label v-text="inputData.label" />
  <input
    v-model="form[inputName]"
    v-bind="inputData.inputAttributes"
  >
</div>

Is that it? You bet. Instead of specifying every field, with a bit of work in the component, we can just loop through fields.

(I should note that this implementation works only an all-<input> form. If your form contains <select> elements, checkboxes, or radio buttons, you may need to create a separate component that will output the correct form field element.)

We've also added some extra type interfaces:

  • UserForm uses some TypeScript magic (index types and index signatures, specifically) to generate a type that represents a user form, using the initial value from our user metadata.
  • For InputAttributes, the HTML attributes we've specified in our metadata take one of three types: boolean for attributes such as [required](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required); number for attributes such as [maxlength](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#htmlattrdefmaxlength); and string for attributes such as [type](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#attr-type).
  • InputData is an object representing the information we need to generate our form. We use the label property for the<label> element and pass inputAttributes to <input> elements using Vue's [v-bind](https://vuejs.org/v2/api/#vm-attrs), like v-bind="inputAttributes". This way we don't have to specify each prop individually.

To get usable inputAttributes, we need to map the name of the metadata field to the correct HTML attribute name. We also need to do null checking for attributes that aren't present on every metadata node, and therefore input element. This work is handled by convertFieldMetadataToInputData.

But why do we need convertFieldMetadataToInputData in the first place?

Well, it's because I didn't plan ahead. If the API metadata used the strings that these HTML attributes expect, we'd be able to use them directly instead of transforming them. Shame on me for not thinking of that earlier!

Wrap-up

There we have it! With a little massaging, we're able to generate a fully-typed form using metadata from a REST-like JSON API. This post also served as an example of how difficult it can be to keep the frontend and backend of a web application in sync—even when attempting to show a method for doing just that.

Still, if done with careful planning, leveraging API metadata can simplify SPA applications and reduce boilerplate and difficult-to-sync code in JavaScript web applications. And by "careful planning" I do mean planning that was more careful than was mine.

Happy coding!