Nuxt Devenir Full-Stack : Comment Valider les Formulaires ?

- Read in English

Depuis des semaines, je construis une application full-stack appelée Orion avec Nuxt et NuxtHub. Tout au long de ce parcours, j'ai rencontré de nombreux défis, et l'un d'eux est la validation des formulaires. Dans cet article, je vais vous montrer comment j'ai réussi à valider les formulaires à la fois côté client et côté serveur sans duplications de la logique de validation, des champs de texte simples aux téléchargements de fichiers complexes avec NuxtHub.

Orion est une collection de modèles basée sur la communauté pour votre prochain projet, des pages de destination aux applications web complètes. NuxtHub est une plateforme de déploiement et d'administration pour Nuxt, propulsée par Cloudflare.

Construire une Application Nuxt Full-Stack sur Twitch

Pendant cet article, je vais parler de NuxtHub car il fournit les primitives pour construire des applications full-stack, mais même si vous ne l'utilisez pas, vous pouvez facilement suivre cet article. Les formulaires seront gérés sans aucune bibliothèque.

Vous pouvez parcourir le code exemple dans validate-forms-in-nuxt.

Le Problème

Sur le web, les formulaires sont omniprésents. Ils sont le principal moyen d'interaction et de demande d'entrée utilisateur. Mais sur le web, il y a une règle essentielle : ne jamais faire confiance au client (absolument jamais et en aucun cas). Cela signifie que toutes les données provenant du client doivent être vérifiées et sanitisées sur le serveur avant d'être traitées ou stockées dans une base de données. Un client peut être un navigateur, une application mobile, une requête HTTP ou tout autre objet capable d'envoyer des données à votre serveur, construit par vous ou non.

Lorsque vous construisez une MPA traditionnelle (Multi-Page Application), seul le serveur est responsable de la validation. Pour une SPA (Single-Page Application), à la fois le client et le serveur sont responsables de la validation pour fournir une meilleure expérience utilisateur. Cela est simplifié car il existe un moyen de donner une impression de validation côté client avec une MPA traditionnelle, mais ce n'est pas le sujet de cet article. MAIS, même si les données sont validées côté client, il est obligatoire de les valider côté serveur.

Le Formulaire le Plus Simple

Commençons notre parcours avec le formulaire le plus simple : une seule entrée de texte.

vue
<template>
  <form method="post" action="/tag">
    <input type="text" placeholder="Nom" required>
    <button type="submit">
      Créer Tag
    </button>
  </form>
</template>

Ensuite, côté serveur, je peux créer une route pour gérer la soumission du formulaire :

ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Enregistrer dans la base de données...

  return sendNoContent(event, 201)
})

Pour l’instant, je n'ai aucune validation. Je peux envoyer n'importe quoi au serveur, et il l'acceptera. C'est un énorme problème de sécurité.

Rappelez-vous que la façon dont je gère la validation est spécifique à une SPA. Une MPA traditionnelle aurait ajouté des erreurs aux messages flash et redirigé la demande.

Valider le Corps

Pour valider le corps côté serveur, j'utilise Zod. Zod est une validation de schéma en TypeScript avec inférence de type statique. La bonne nouvelle est qu'h3, le routeur et le framework web qui alimentent Nitro, la couche serveur de Nuxt, fournit une utilitaire pour valider le corps : readValidatedBody.

ts
import { z } from 'zod'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, z.object({
    name: z.string(),
  }).parse)

  // Enregistrer dans la base de données...

  return sendNoContent(event, 201)
})

L'utilitaire readValidatedBody lancera automatiquement une erreur si le corps ne correspond pas au schéma. Par exemple, si le nom est manquant, la requête échouera.

Valider les Données avec H3

Par défaut, l'erreur sera gérée par le serveur et lancera une erreur 500, mais je peux capturer l'erreur pour renvoyer un message d'erreur plus convivial.

La première solution consiste à utiliser la méthode safeParse qui ne lancera pas d'erreur mais renverra un résultat avec l'erreur le cas échéant.

ts
import { z } from 'zod'

export default defineEventHandler(async (event) => {
  const result = await readValidatedBody(event, z.object({
    name: z.string(),
  }).safeParse)

  if (!result.success) {
    throw createError({
      status: 400,
      message: 'Données non valides',
      data: result.error.errors[0].message,
    })
  }

  const body = result.data

  // Enregistrer dans la base de données...

  return sendNoContent(event, 201)
})

La seconde solution consiste à utiliser la méthode parseAsync pour attacher une gestion d'erreur manuelle à la promesse.

ts
import type { ZodError } from 'zod'
import { z } from 'zod'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, data => z.object({
    name: z.string(),
  }).parseAsync(data).catch((error) => {
    if (error instanceof ZodError) {
      throw createError({
        status: 400,
        message: 'Données non valides',
        data: error.errors[0].message,
      })
    }
  }))

  // Enregistrer dans la base de données...

  return sendNoContent(event, 201)
})

Vous pourriez envelopper le validateur avec un try/catch, mais je trouve cela moins élégant et lisible.

Valider le Frontend

Côté frontend, je peux également utiliser Zod pour valider le formulaire avant de l'envoyer au serveur.

vue
<script lang="ts" setup>
import type { ZodError } from 'zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string({ message: 'Obligatoire' }), // Je peux personnaliser le message d'erreur affiché à l'utilisateur
})

const state = reactive({
  name: '',
})

async function onSubmit() {
  try {
    const data = schema.parse(state)
    await $fetch('/tag', {
      method: 'post',
      body: data,
    })
  }
  catch (error) {
    if (error instanceof ZodError) {
      throw createError({
        status: 400,
        message: 'Données non valides',
        data: error.errors,
      })
    }
  }
}
</script>

<template>
  <form @submit.prevent="onSubmit()">
    <input v-model="state.name" type="text" placeholder="Nom" required>
    <button type="submit">
      Créer Tag
    </button>
  </form>
</template>

L'implémentation est très simple et directe. Je pourrais enregistrer les erreurs dans une variable pour les afficher à l'utilisateur. Si vous souhaitez aller plus loin, je vous recommande d'essayer le composant Form de Nuxt UI ou une bibliothèque de validation comme VeeValidate ou FormKit.

Refactorisons

Maintenant que j'ai la logique de validation à la fois côté client et côté serveur, voyons s'il y a un code intéressant à refactoriser.

La première chose que je remarque est que la logique de validation, côté client et serveur, est la même, et c'est totalement normal. Mais que se passe-t-il si je décide de changer la logique de validation côté serveur parce que mon API est utilisée par une autre application ? Mon frontend pourrait être cassé car la logique de validation sera différente. Pour éviter cela, il serait peut-être mieux d'écrire le validateur une seule fois et de l'utiliser des deux côtés. Qu'en pensez-vous ?

Je peux créer un fichier validators.ts dans le dossier utils de la partie client (à la racine) d'une application Nuxt. Ce fichier contiendra tous les validateurs pour mon application et sera partagé entre le client et le serveur.

ts
import { z } from 'zod'

export const createTagValidator = z.object({
  name: z.string({ message: 'Obligatoire' }),
})

Tip

Ce fichier peut être utilisé côté serveur mais a été créé côté client, donc vous devez explicitement importer tout ce dont vous avez besoin pour éviter des problèmes de rendu.

Ce validateur contient le message pour l'erreur pour aider l'utilisateur à comprendre ce qui ne va pas.

Grâce à la fonction d'auto-import de Nuxt, je peux facilement l’utiliser côté client :

vue
<script lang="ts" setup>
const schema = createTagValidator

// ...
</script>

<template>
  <form>
  <!-- ... -->
  </form>
</template>

Ensuite, vous pouvez également l'utiliser côté serveur en l'importation :

ts
import { createTagValidator } from '~/utils/validators'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, createTagValidator.parse)

  // Enregistrer dans la base de données...

  return sendNoContent(event, 201)
})

Maintenant, je peux changer en toute sécurité la logique de validation sans casser le frontend, car ils suivent les mêmes règles, et je n'ai pas à dupliquer le code.

Gestion des Fichiers

Pour cette partie, je vais vous montrer deux façons différentes de valider les fichiers côté serveur. La première utilisera Zod et quelques raffinements, et la seconde utilisera l'utilitaire ensureBlob de NuxtHub.

Cette validation n'est pas complète et devrait être améliorée pour gérer plus de cas.

Avec Zod

Pour cette seconde partie, je vais vous montrer comment télécharger un fichier sur le serveur. D'abord, ajoutons notre entrée dans le formulaire :

vue
<template>
  <form action="/api/image" enctype="multipart/form-data" method="post">
    <input type="file" accept="image/png, image/jpeg, image/jpg" required>
    <button type="submit">
      Créer Tag
    </button>
  </form>
</template>

Ensuite, je peux gérer le fichier côté serveur, mais c'est un peu plus complexe car j'envoie un formData et h3 ne fournit pas d'utilitaire comme readValidatedFormData. Donc, je dois le gérer manuellement.

D'abord, je vais lire le formData en utilisant l'utilitaire readFormData, puis j'extraire l'image du formData et la valider en utilisant Zod.

ts
import { any, object } from 'zod'

export default defineEventHandler(async (event) => {
  const formData = await readFormData(event)

  const image = formData.get('image')
  await object({
    image: any().refine(image => image instanceof File, { message: 'L\'image doit être un fichier' })
      .refine(image => image.size < 1024 * 1024, { message: 'L\'image doit faire moins de 1 Mo' })
      .refine(image => image.type.startsWith('image/'), { message: 'L\'image doit être une image' })
      .refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'L\'image doit être un JPEG ou un PNG' })
  }).parseAsync({ image }).catch((error) => {
    throw createError({
      status: 400,
      message: 'Image non valide',
      data: error.errors[0].message
    })
  })

  // Enregistrer l'image dans une base de données ou un système de fichiers

  return sendNoContent(event, 201)
})

Côté client, je peux également valider le fichier en utilisant la même technique qu'auparavant et en partageant le validateur entre le client et le serveur.

D'abord, je dois déplacer le validateur dans le fichier utils/validators.ts :

ts
import { any, object } from 'zod'

export const createImageValidator = object({
  image: any().refine(image => image instanceof File, { message: 'L\'image doit être un fichier' })
    .refine(image => image.size < 1024 * 1024, { message: 'L\'image doit faire moins de 1 Mo' })
    .refine(image => image.type.startsWith('image/'), { message: 'L\'image doit être une image' })
    .refine(image => ['image/jpeg', 'image/png'].includes(image.type), { message: 'L\'image doit être un JPEG ou un PNG' })
})

Ensuite, je peux l'utiliser lors de la soumission du formulaire pour éviter d'envoyer des données non valides au serveur :

vue
<script lang="ts" setup>
async function onImageSubmit(event: Event) {
  const target = event.target as HTMLFormElement
  const formData = new FormData(target)

  const image = formData.get('image')
  try {
    await createImageValidator.parseAsync({ image })

    await $fetch('/api/image', {
      method: 'POST',
      body: formData,
    })
  }
  catch (error) {
    console.error(error)
  }
}
</script>

<template>
  <div>
    <h1>Valider les Formulaires avec Nuxt</h1>

    <h2>
      Image
    </h2>
    <form enctype="multipart/form-data" @submit.prevent="onImageSubmit($event)">
      <input type="file" name="image" accept="image/png, image/jpeg, image/jpg">
      <button type="submit">
        Soumettre
      </button>
    </form>
  </div>
</template>

Si la validation échoue, la requête ne sera pas envoyée au serveur et l'erreur sera affichée dans la console. Bien sûr, vous pouvez gérer l'erreur comme vous le souhaitez, comme utiliser un toast ou afficher un message d'erreur sous l'entrée concernée.

Côté serveur, je peux remplacer la logique de validation par le validateur :

ts
import { saveImageValidator } from '~/utils/validators'

export default defineEventHandler(async (event) => {
  // ...

  await saveImageValidator.parseAsync({ image })

  // ...
})

Avec NuxtHub

NuxtHub est livré avec un utilitaire nommé ensureBlob qui peut être utilisé pour vérifier la taille et le type du fichier. Cet utilitaire est très utile car il lancera automatiquement une erreur si le fichier n'est pas valide, mais ne peut être utilisé que côté serveur. Cela signifie que j'aurai un schéma Zod côté client et un utilitaire NuxtHub côté serveur. Utilisez des constantes pour partager les limites de validation entre le schéma Zod et l'utilitaire NuxtHub.

ts
export default defineEventHandler(async (event) => {
  const formData = await readFormData(event)

  const image = formData.get('image') as Blob
  ensureBlob(image, {
    maxSize: '1Mo',
    types: ['image/png', 'image/jpeg', 'image/jpg']
  })

  // Enregistrer l'image dans une base de données ou un système de fichiers

  return sendNoContent(event, 201)
})

Texte et Blob dans le Même Formulaire

Jusqu'à présent, j'ai seulement un type d'entrée dans mon formulaire, texte ou fichier. Mais que se passe-t-il si je veux avoir les deux dans le même formulaire ?

Vous devez savoir que vous devez absolument utiliser un formData pour envoyer le formulaire au serveur en raison de l'entrée de fichier.

Le formulaire est toujours très simple :

vue
<template>
  <form action="/api/text-image" enctype="multipart/form-data" method="post">
    <input type="text" name="name" placeholder="Nom" required>
    <input type="file" name="image" accept="image/png, image/jpeg, image/jpg" required>
    <button type="submit">
      Créer Tag
    </button>
  </form>
</template>

Maintenant, je peux créer un validateur en utilisant Zod :

ts
import { any, object, string } from 'zod'

export const createValidator = object({
  name: string({ message: 'Obligatoire' }),
  image: any().refine(image => image instanceof File, { message: 'L\'image doit être un fichier' })
    .refine(image => image.size < 1024 * 1024, { message: 'L\'image doit faire moins de 1 Mo' })
    .refine(image => image.type.startsWith('image/'), { message: 'L\'image doit être une image' })
    .refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'L\'image doit être un JPEG ou un PNG' })
})

Ce validateur peut être utilisé comme auparavant, sur le serveur et le client.

Si vous préférez utiliser l'utilitaire ensureBlob de NuxtHub, je vous recommande de diviser le validateur en deux parties : une pour le texte et une pour l'image. Cela signifie que le texte et l'image seront utilisés côté client en les fusionnant en un seul objet, et uniquement le texte sera utilisé côté serveur.

ts
import { any, object, string } from 'zod'

export const createTextValidator = object({
  name: string({ message: 'Obligatoire' }),
})

export const createImageValidator = object({
  image: any().refine(image => image instanceof File, { message: 'L\'image doit être un fichier' })
    .refine(image => image.size < 1024 * 1024, { message: 'L\'image doit faire moins de 1 Mo' })
    .refine(image => image.type.startsWith('image/'), { message: 'L\'image doit être une image' })
    .refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'L\'image doit être un JPEG ou un PNG' })
})

export const createValidator = createTextValidator.merge(createImageValidator)

Côté client, j'utiliserai le createValidator pour valider le formulaire avant de l'envoyer au serveur :

vue
<script lang="ts" setup>
async function onSubmit(event: Event) {
  const target = event.target as HTMLFormElement
  const formData = new FormData(target)

  const name = formData.get('name')
  const image = formData.get('image')
  try {
    await createValidator.parseAsync({ name, image })

    await $fetch('/api/text-image', {
      method: 'POST',
      body: formData,
    })
  }
  catch (error) {
    console.error(error)
  }
}
</script>

Côté serveur, j'utiliserai le createTextValidator pour valider le texte et l'utilitaire ensureBlob pour valider l'image :

ts
import { createTextValidator } from '~/utils/validators'

export default defineEventHandler(async (event) => {
  const formData = await readFormData(event)

  const name = formData.get('name')
  const data = await createTextValidator.parseAsync({ name }).catch((error) => {
    throw createError({
      status: 400,
      message: 'Données non valides',
      data: error.errors[0].message
    })
  })

  // Enregistrer le texte dans la base de données...

  const image = formData.get('image') as Blob
  ensureBlob(image, {
    maxSize: '1 Mo',
    types: ['image/png', 'image/jpeg', 'image/jpg']
  })

  // Enregistrer l'image dans une base de données ou un système de fichiers

  return sendNoContent(event, 201)
})

Pour des formulaires plus longs, je pourrais utiliser une boucle pour extraire chaque champ du formulaire et les valider. Dans cette boucle, je peux gérer des cas spécifiques comme des tableaux de champs multi-sélection ou des fichiers ou ignorer certains champs (qui pourraient être validés en utilisant ensureBlob).

ts
const data = {}
for (const [key, value] of formData.entries()) {
  if (key === 'moduleId[]') {
    data[key] = formData.getAll(key)
    continue
  }
  else if (key === 'image') {
    continue
  }
  else {
    data[key] = value
  }
}

Conclusion

Et voilà ! Vous êtes maintenant capables de valider des formulaires à la fois côté client et côté serveur sans dupliquer la logique de validation. C'est un grand pas en avant dans la construction d'une application full-stack avec Nuxt. J'espère que cet article vous aidera à construire votre prochaine application en toute confiance. Vous devriez absolument consulter la documentation de NuxtHub pour en savoir plus sur la plateforme et le pouvoir qu'elle offre pour construire des applications full-stack avec Nuxt.

N'hésitez pas à me faire savoir si vous avez des questions ou des commentaires. Je pourrais faire quelques erreurs, alors n'hésitez pas à me corriger. Je suis toujours à la recherche de moyens pour améliorer mon code et mes articles.

Merci de votre lecture !

Retour aux articles
Soutenez mon travail
Suivez-moi sur