Un backend API Vanilla, sans dépendance, avec Deno

Photo by Luca Upper on Unsplash

Un backend API Vanilla, sans dépendance, avec Deno

Introduction

Quand il s'agit de réaliser un backend API RESTful en JS, classiquement avec NodeJS, on a tendance à partir sur un framework comme ExpressJS, Fastify, NestJS ou autre… ; et on peut difficilement envisager de faire autrement tant la librairie HTTP standard de NodeJS est pauvre.

En effet, il suffit de consulter la documentation http server pour se rendre compte du côté spartiate de la lib et du travail que l'on va devoir entreprendre pour atteindre le béaba.

Dans ces conditions, un serveur API minimum vanilla ressemblerait à ça :

const http = require('http')
const server = http.createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/tasks') {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.write(JSON.stringify(await getTasks()))
    res.end()
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ message: 'Resource not found' }))
  }
})
server.listen(8000, () => {
  console.log('Server started.')
})

Dans les choses peu élégantes on peut noter l'appel récurrent à stringify, le listen en mode callback (vs promesse), la nécessité de déclarer les entêtes pour répondre en JSON.

Avec ExpressJS, cela donnerait :

const express = require('express')
const app = express()
app.get('/tasks', async (req, res) => {
  res.json(await getTasks())
})
app.listen(8000, () => {
  console.log('Server started.')
})

Que fournit Deno ?

Avec Deno, on en a un peu plus que NodeJS de base avec le module http de la lib std ; on a par exemple des facilités avec JSON grâce à la présence d'objets Request et Response, mais il manque toujours un mécanisme de routage.

Le but de cet article est de vous montrer comment, avec un peu d'effort, on peut facilement combler ce manque et obtenir un backend API parfaitement fonctionnel sans aucune dépendance (à part la lib std).

Moins de dépendances, c'est mieux

Minimiser les dépendances présente quelques inconvénients, mais aussi des avantages :

  • On doit souvent réinventer la roue et créer des helpers pour rendre le développement plus confortable, ce qui peut représenter une surcharge non négligeable dans un projet.

  • Moins de dépendances, c'est moins d'adhérence avec des éléments externes, c'est moins de risque sécuritaire, c'est moins de problèmes à maintenir à niveau des versions avec d'éventuels changements qui cassent, et surtout c'est beaucoup plus sustainable car moins consommateur en IO réseaux, espace disque, et pour les CI/CD en temps d'exécution des pipelines.

Tout est affaire de curseur et de stratégie…, une chose est sûre : si votre besoin est suffisamment simple pour être couvert même partiellement avec ce qui est déjà fourni vous devriez au moins vous poser la question d'utiliser au maximum ce qui est déjà dans la boîte quitte à devoir coder un peu autour, plutôt que de foncer tête baissée sur un gros framework dont vous n'exploiterez probablement que 20%.

« Toute chose ressemble à un clou, pour celui qui ne possède qu'un marteau » (Abraham Maslow)

Dans ce hands-on, nous allons implémenter une API de gestion de tâches avec Deno et seulement Deno. Pour pouvoir gérer complètement nos tâches nous allons exposer les routes suivantes :

  • GET /tasks : obtenir la liste des tâches

  • POST /tasks : créer une tâche

  • DELETE /tasks : supprimer toutes les tâches

  • GET /tasks/{id} : obtenir une tâche d'après son ID

  • DELETE /tasks/{id} : supprimer une tâche

  • PUT /tasks/{id} : modifier complètement une tâche

  • PATCH /tasks/{id} : modifier partiellement une tâche

1. Obtenir la liste des tâches

Retrouvez les sources de l'étape 1

L'objectif est d'implémenter un premier serveur simple qui répond en JSON (les données sont fournies par le modèle "task")

Pour réaliser un serveur HTTP avec Deno, 2 choix sont possibles : utiliser la fonction Deno.serveHttp de l'API native bas-niveau ou la fonction serve du module std/http haut-niveau

Dans le 1er cas, on va devoir appeler Deno.serveHttp() puis appeler nextRequest() sur la connexion pour obtenir un objet RequestEvent et travailler avec cet objet pour répondre au client. On peut s'affranchir de nextRequest() en choisissant plutôt une approche du type itérateur asynchrone, comme illustré dans la documentation de l'API (pas toujours évident d'être à l'aise avec cette forme de programmation).

Illustration :

// etc/deno_vanilla_server.ts
const handleHttp = async (conn: Deno.Conn) => {
  for await (const requestEvent of Deno.serveHttp(conn)) {
      const req = requestEvent.request
      const { pathname } = new URL(req.url)
      if (req.method === 'GET' && pathname === '/tasks') {
        void requestEvent.respondWith(Response.json(await getTasks()))
        return
      }
      void requestEvent.respondWith(Response.json({ message: 'Resource not found' }, {
        status: 404,
      }))
  }
}
for await (const conn of Deno.listen({ port: 8000 })) {
  void handleHttp(conn)
}

😫 Compliqué ! Et assez subtil (le pourquoi il ne faut pas await systématiquement pourrait faire l'objet d'un article dédié…)

Avec le module std/http les choses deviennent plus confortables :

// index.ts
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'

await serve(async (req) => {
  const { pathname } = new URL(req.url)
  if (req.method === 'GET' && pathname === '/tasks') {
    return Response.json(getTasks())
  }
  return Response.json({ message: 'Resource not found' }, {
    status: 404,
  })
}, { port: 8000 })

Nous ajoutons ici une dépendance, ce qui peut paraître contradictoire avec l'un de nos objectifs, mais… il s'agit d'un composant standard de Deno, qui est très stable et sans adhérence tierce ; on reste dans la philosophie de Deno.

Test de notre route :

Démarrage du serveur :
deno task dev

Résultat :

Task dev deno run --watch --allow-env --allow-net index.ts
Watcher Process started.
Listening on http://localhost:8000/
Consommation de la route :
http :8000/tasks

utilisation de la CLI httpie (équivalent plus moderne de cURL)

Résultat :

HTTP/1.1 200 OK
…
content-type: application/json
…

[
    {
        "done": true,
        "id": "1",
        "name": "Milk"
    },
    {
        "done": false,
        "id": "2",
        "name": "Beer"
    }
]

2. Créer des tâches

Retrouvez les sources de l'étape 2

Pour ajouter des tâches on va devoir gérer le payload JSON de la requête, Deno fournit ce qu'il faut avec req.json() qui retourne un type any. On va s'empresser de typer notre payload pour ne pas laisser any dans notre code, qui est une mauvaise pratique :

// model/task.ts
type Task = {
  id: string
  name: string
  done: boolean
}

export type TaskPayload = { name: string; done?: string }

Une tâche doit être complètement définie, tandis qu'un payload de tâche ne fournit pas d'ID et pas nécessairement de propriété done ; plus d'infos dans ./model/task.ts.

On ajoute la route POST /tasks à notre serveur pour pouvoir créer des nouvelles tâches :

// index.ts
import type { TaskPayload } from './model/task.ts'
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'
import { addTask, getTasks } from './model/task.ts'

await serve(async (req) => {
  const { pathname } = new URL(req.url)
  if (req.method === 'GET' && pathname === '/tasks') {
    return Response.json(getTasks())
  }
  if (req.method === 'POST' && pathname === '/tasks') {
    const taskPayload: TaskPayload = await req.json()
    const task = addTask(taskPayload)
    return Response.json(task)
  }
  return Response.json({ message: 'Resource not found' }, {
    status: 404,
  })
}, { port: 8000 })

Test d'ajout d'une tâche :

http :8000/tasks "name=Ice cream"

Résultat :

HTTP/1.1 200 OK
…
{
    "done": false,
    "id": "3",
    "name": "Ice cream"
}

A ce stade on peut observer que le manque de mécanisme de routage est pénalisant, devoir tester à chaque fois la méthode et le path de l'URI alourdit le code lorsque les routes sont nombreuses.

3. Ajout d'un Route Matcher

Retrouvez les sources de l'étape 3

Pour rendre le code du serveur plus élégant, nous allons nous doter d'un composant responsable de vérifier si une route correspond à la requête, et en profiter pour formaliser ce qu'est une route.

Dans les APIs web standard, que respecte Deno, on dispose d'une classe URLPattern bien pratique pour notre besoin.

Commençons par un peu de déclaratif TypeScript…

Définition des contrats d'interfaces :

// types/router.d.ts
export type RequestMatcher = (req: Request) => false | URLPatternResult
export type RouteHandler = (
  request: Request,
  connInfo: ConnInfo,
  options: { pattern?: URLPatternResult },
) => RouteHandlerResult
export type RouteHandlerResult =
  | Promise<Response | JSONValue>
  | Response
  | JSONValue
export type Route = {
  matcher: RequestMatcher
  handler: RouteHandler
}
export type RouteSpec = {
  method?: HttpMethod
  path?: string
}

Explications :

  • on veut qu'une fonction du type RequestMatcher prenne une requête HTTP en entrée et retourne false ou bien un résultat de pattern d'URL (qui servira aussi à obtenir des parties dynamiques de l'URI).
  • un handler de requête (souvent appelé "contrôleur" dans des modèles MVC) doit prendre en paramètres la requête, des infos sur la connexion, le résultat du matching de l'URL, et retourner un résultat souple qui peut être soit un objet Response, soit directement un objet JSON, soit une promesse des deux.
  • une Route est un objet qui associe un matcher et un handler.
  • Une RouteSpec représente la spécification d'une route à déclarer : soit une méthode et un chemin.

On souhaite que notre nouveau serveur ressemble à ceci :

// index.ts
import type { TaskPayload } from './model/task.ts'
import type { Route } from './types/router.d.ts'
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'
import { createRouteMatcher } from './http/route_matcher.ts'
import { addTask, getTasks } from './model/task.ts'
import { asPromise } from './utils/helper.ts'

const routes: Route[] = [{
  matcher: createRouteMatcher({ method: 'GET', path: '/tasks' }),
  handler: () => getTasks(),
}, {
  matcher: createRouteMatcher({ method: 'POST', path: '/tasks' }),
  handler: async (req) => {
    const taskPayload: TaskPayload = await req.json()
    return addTask(taskPayload)
  },
}]

await serve(async (req, connInfo) => {
  for (const route of routes) {
    const urlPatternResult = route.matcher(req)
    if (urlPatternResult) {
      const result = await asPromise(
        route.handler(req, connInfo, { pattern: urlPatternResult }),
      )
      return result instanceof Response ? result : Response.json(result)
    }
  }
  return Response.json({ message: 'Resource not found' }, {
    status: 404,
  })
}, { port: 8000 })

Le rôle du handler principal de requête consiste à parcourir la liste des routes déclarées et si une route matche, déléguer au handler le traitement de la requête.

La fonction de création de matcher de route va prendre en paramètre une RouteSpec et retourner un nouveau RequestMatcher.

// http/route_matcher.ts
import type { RequestMatcher, RouteSpec } from '../types/router.d.ts'

export const createRouteMatcher = (routeSpec: RouteSpec): RequestMatcher => {
  const methodMatcher = !routeSpec.method
    ? () => true
    : (method: string) => method === routeSpec.method
  const urlPattern = routeSpec.path
    ? new URLPattern({ pathname: routeSpec.path })
    : undefined
  return (req) => {
    if (!methodMatcher(req.method)) {
      return false
    }
    const { pathname } = new URL(req.url)
    return urlPattern?.exec({ pathname }) || false
  }
}

A noter l'utilisation respectueuse des types, parfois implicites, souvent explicites, sans jamais laisser un any (choix d'un TypeScript le plus strict possible pour bénéficier d'un maximum de qualité)

4. Ajout d'un Route Handler

Retrouvez les sources de l'étape 4

Le code du handler principal de requête est un peu lourd, on peut le déporter dans une fonction qui va prendre en charge le fait de scruter les routes et choisir la bonne.

Notre serveur donnerait ceci :

// index.ts
import type { TaskPayload } from './model/task.ts'
import type { Route } from './types/router.d.ts'
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'
import { createRouteMatcher } from './http/route_matcher.ts'
import { addTask, getTasks } from './model/task.ts'
import { createRouteHandler } from './http/route_handler.ts'

const routes: Route[] = [{
  matcher: createRouteMatcher({ method: 'GET', path: '/tasks' }),
  handler: () => getTasks(),
}, {
  matcher: createRouteMatcher({ method: 'POST', path: '/tasks' }),
  handler: async (req) => {
    const taskPayload: TaskPayload = await req.json()
    return addTask(taskPayload)
  },
}]

const handler = createRouteHandler(routes)

await serve(handler, { port: 8000 })

Beaucoup moins verbeux, plus élégant, meilleur découpage

La fonction en charge de créer le Route Handler :

// http/route_handler.ts
import type { Handler } from '../deps.ts'
import type { Route, RouterOptions } from '../types/router.d.ts'
import { asPromise } from '../utils/helper.ts'

export const defaultNotFoundHandler: Handler = () =>
  Response.json({ message: 'Resource not found' }, {
    status: 404,
  })

export const createRouteHandler = (
  routes: Route[],
  options?: RouterOptions,
): Handler => {
  const notFoundHandler = options?.notFound || defaultNotFoundHandler
  return async (req, connInfo) => {
    for (const route of routes) {
      const urlPatternResult = route.matcher(req)
      if (urlPatternResult) {
        const result = await asPromise(
          route.handler(req, connInfo, { pattern: urlPatternResult }),
        )
        return result instanceof Response ? result : Response.json(result)
      }
    }
    return notFoundHandler(req, connInfo)
  }
}

L'utilitaire asPromise permet de transformer un résultat "N" ou "promesse de N" en une "promesse de N", il sert de glue pour faciliter le code consommateur en exploitant au mieux les types génériques et les "types guards" :

// utils/helper.ts
export type AsPromise = <T>(value: T | Promise<T>) => Promise<T>
export type IsPromise = <T>(value: T | Promise<T>) => value is Promise<T>

export const asPromise: AsPromise = <T>(value: T | Promise<T>): Promise<T> =>
  isPromise(value) ? value : Promise.resolve(value)

export const isPromise: IsPromise = <T>(
  value: T | Promise<T>,
): value is Promise<T> =>
  typeof value === 'object' &&
  typeof (value as Promise<unknown>).then === 'function'

5. Ajout de routes

Retrouvez les sources de l'étape 5

Maintenant qu'on dispose de composants pratiques qui factorisent les mécanismes de routing, il est temps d'ajouter quelques features en complétant notre serveur avec d'autres méthodes HTTP pour gérer le détail et la suppression de tâches :

Nouvelles routes :

  • DELETE /tasks : supprimer toutes les tâches
  • GET /tasks/{id} : obtenir une tâche d'après son ID
  • DELETE /tasks/{id} : supprimer une tâche

Les deux dernières routes utilisent une partie dynamique dans le chemin, liée à l'ID d'une tâche ; c'est là qu'URLPattern et notre RouteMatcher va faire son effet.

// index.ts
import type { TaskPayload } from './model/task.ts'
import type { Route } from './types/router.d.ts'
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'
import { createRouteMatcher } from './http/route_matcher.ts'
import {
  addTask,
  clearTasks,
  getTask,
  getTasks,
  removeTask,
} from './model/task.ts'
import { createRouteHandler } from './http/route_handler.ts'

const notFoundResponse = Response.json({ message: 'Task not found' }, {
  status: 404,
})

const routes: Route[] = [{
  matcher: createRouteMatcher({ method: 'GET', path: '/tasks' }),
  handler: () => getTasks(),
}, {
  matcher: createRouteMatcher({ method: 'POST', path: '/tasks' }),
  handler: async (req) => {
    const taskPayload: TaskPayload = await req.json()
    return addTask(taskPayload)
  },
}, {
  matcher: createRouteMatcher({ method: 'GET', path: '/tasks/:id' }),
  handler: (_req, _connInfo, { pattern }) => {
    const id = pattern?.pathname.groups.id
    const task = id && getTask(id)
    if (!task) {
      return notFoundResponse
    }
    return task
  },
}, {
  matcher: createRouteMatcher({ method: 'DELETE', path: '/tasks/:id' }),
  handler: (_req, _connInfo, { pattern }) => {
    const id = pattern?.pathname.groups.id
    const removed = id && removeTask(id)
    if (!removed) {
      return notFoundResponse
    }
    return new Response(undefined, { status: 204 })
  },
}, {
  matcher: createRouteMatcher({ method: 'DELETE', path: '/tasks' }),
  handler: () => {
    clearTasks()
    return new Response(undefined, { status: 204 })
  },
}]

const handler = createRouteHandler(routes)

await serve(handler, { port: 8000 })

Lorsqu'une route référence une tâche qui n'existe pas, on répond une erreur 404 en JSON.

6. Un routeur pour emballer le tout

Retrouvez les sources de l'étape 6

En guise de bouquet final, on va aller un peu plus loin et utiliser un composant Router qui va encapsuler toute la complexité et proposer une technique de chaînage au serveur :

// index.tsconst router = createRouter()
  .get('/tasks', () => getTasks())
  .post('/tasks', async (req) => {
    const taskPayload: TaskPayload = await req.json()
    return addTask(taskPayload)
  })
  .get('/tasks/:id', (_req, _connInfo, { pattern }) => {
    const id = pattern?.pathname.groups.id
    const task = id && getTask(id)
    if (!task) {
      return notFoundResponse
    }
    return task
  })
  .delete('/tasks/:id', (_req, _connInfo, { pattern }) => {
    const id = pattern?.pathname.groups.id
    const removed = id && removeTask(id)
    if (!removed) {
      return notFoundResponse
    }
    return new Response(undefined, { status: 204 })
  })
  .delete('/tasks', () => {
    clearTasks()
    return new Response(undefined, { status: 204 })
  })

await routeServe(router, { port: 8000 })

Le composant Router qui permet cette utilisation :

// http/router.ts
import type {
  HttpMethod,
  Route,
  RouteHandler,
  Router,
  RouterOptions,
} from '../types/router.d.ts'
import { createRouteHandler } from './route_handler.ts'
import { createRouteMatcher } from './route_matcher.ts'
import { httpMethods } from './http_method.ts'

export const routeRegisterer = (routes: Route[]) =>
(
  method: HttpMethod,
  path: string,
  handler: RouteHandler,
) => {
  if (!httpMethods.includes(method)) {
    throw new Error(`HTTP method '${method}' not supported`)
  }
  const matcher = createRouteMatcher({ method, path })
  const route: Route = { matcher, handler }
  routes.push(route)
}

export const createRouter = (options?: RouterOptions): Router => {
  const routes: Route[] = []
  const registerRoute = routeRegisterer(routes)
  const createHttpMethodFunc = (method: HttpMethod) =>
  (
    path: string,
    routeHandler: RouteHandler,
  ) => {
    registerRoute(method, path, routeHandler)
    return router
  }
  const router: Router = {
    delete: createHttpMethodFunc('DELETE'),
    get: createHttpMethodFunc('GET'),
    head: createHttpMethodFunc('HEAD'),
    options: createHttpMethodFunc('OPTIONS'),
    patch: createHttpMethodFunc('PATCH'),
    post: createHttpMethodFunc('POST'),
    put: createHttpMethodFunc('PUT'),
    register: registerRoute,
    toHandler: () => createRouteHandler(routes, options),
  }
  return router
}

A aucun moment nous n'avons eu besoin de créer des classes, tout a été réalisé en privilégiant la programmation fonctionnelle (plus efficiente et adaptée aux situations où les notions d'instance et d'héritage ne sont pas nécessaires).

Dernière pièce du puzzle : le helper capable de lancer un serveur directement sur la base d'un Router

import type { ServeInit } from 'https://deno.land/std@0.185.0/http/server.ts'
import type { Router } from '../types/router.d.ts'
import { serve } from 'https://deno.land/std@0.185.0/http/server.ts'

export const routeServe = (router: Router, options?: ServeInit) =>
  serve(router.toHandler(), options)

Il est facile de faire évoluer la base proposée pour aller encore plus loin avec un FileRouterHandler qui vous permettrait de déclarer vos routes par convention en créant simplement un composant au bon endroit de la hiérarchie sous "./routes" par exemple, à l'instar de ce que propose SvelteKit.

Conclusion

Nous avons vu qu'il n'est pas si difficile de se passer d'un framework et obtenir un résultat aussi efficient.

Il est possible que vous trouviez que le code des composants techniques qu'il a fallu implémenter est assez exigeant, c'est tout à fait légitime ; il faut juste garder en tête que :

  • Plus ces composants seront bien conçus, et plus ils seront résistants dans le temps.
  • Vous n'aurez à développer cette partie qu'une seule fois et l'ajouter à votre boîte à outils, avec un bon niveau de capitalisation.
  • Vous maîtrisez tout et ne dépendez de personne (ou presque).
  • Accessoirement ce genre d'exercice de style fait beaucoup progresser : s'habituer à développer des librairies / utilitaires est un excellent complément au développement d'applications pour parfaire ses compétences de développeur.
  • Vous minimisez le coût en performance à l'exécution.
  • Vous faîtes un petit geste pour la planète.

S'agissant de l'empreinte du projet et des performances auxquelles on peut s'attendre, quelques indicateurs :

  • Taille du projet sans les dépendances :
    deno info --no-remote index.ts 
    …
    size: 10.3KB
    
  • Taille des dépendances :
    deno info deps.ts
    …
    size: 42.27KB
    
  • Globalement le projet pèse 52.51Ko

Avec un peu de chance, cet exemple vous aura donné envie d'adopter Deno pour votre prochain projet 😉

Retrouvez l'intégralité des sources proposés sur GitHub Niji Digital, avec quelques bonus :

  • Les routes PUT et PATCH.
  • La persistance des tâches via localstorage, bien pratique comme solution de persistance légère pour des tests ou démos.