Two whale tails emerging from water
Two whale tails emerging from water

Photo by Steve Halama on Unsplash

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

(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. DRF - 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:

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.

Rick Moranis with a magnifying glass in Honey I Shrunk the Kids

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:

Enter User metadata.

There are a number of ways to use the metadata specified in User.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 has resolveJSONModule: true.)

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

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:

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?

Rowan Atkinson looking guilty as Mr. Bean

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!