Uploader des fichiers via un middleware GraphQL

GraphQL est une implémentation très intéressante pour que les fronts de vos applications web communiquent de manière efficace avec vos différentes sources backends (et fasse de l'aggrégation si nécessaire) et récupère également les informations qui seront utiles à l'application uniquement. Basé sur un système de requêtage en JSON (query ou mutation), l'entrée de la requête HTTP au serveur GraphQL est ainsi du type application/json.

GraphQL Go Upload

Pour un client, j'ai eu besoin d'uploader (via une mutation donc) des informations à propos d'un contenu média afin de gérer une médiathèque, un média se composant d'un titre et d'une image source. C'est ici que ça coince : nous allons avoir besoin d'effectuer une requête HTTP POST de type multipart/form-data contenant nos meta-données et notre fichier source. Dans mon cas et pour cet article, le serveur GraphQL est ici développé en Go via la librairie GraphQL-Go mais cet article peut être utilisé pour tous les langages.

Il y a donc deux étapes à réaliser :

  • Ajouter un nouveau type scalaire (que l'on nommera Upload) en entrée de notre mutation,
  • Développer un middleware afin d'intercepter la requête HTTP POST multipart/form-data et la re-mapper pour GraphQL.

Déclaration d'un nouveau type scalaire : Upload

La première étape est donc de déclarer notre mutation qui ressemblera à cela dans notre schéma :

scalar Upload

type Mutation {
    myUploadMutation(file: Upload!, title: String!): Boolean
}

Nous déclaratons donc ici un nouveau type scalaire "Upload" et l'utilisons sous l'entrée "file" dans la mutation ci-dessous.

Ce type doit également être déclaré dans notre code Go, nous allons donc déclarer une nouvelle structure dans notre code :

type GraphQLUpload struct {
    Filename string `json:"filename"`
    MIMEType string `json:"mimetype"`
    Filepath string `json:"filepath"`
}

Ainsi, lorsque nous uploaderons un média, trois informations seront remontées : le nom du fichier, le MIME type du fichier (application/json, image/png, ...) ainsi que le chemin vers le fichier temporairement stocké sur le serveur GraphQL. GraphQL-Go nous demande d'implémenter une interface Unmarshaler définissant deux méthodes ImplementsGraphQLType(name string) permettant de définir le nom de notre type scalaire (Upload dans notre cas) et UnmarshalGraphQL(input interface{}) permettant de transformer l'entrée reçue en structure GraphQLUpload dans notre cas :

func (u GraphQLUpload) ImplementsGraphQLType(name string) bool {
    return name == "Upload"
}

func (u *GraphQLUpload) UnmarshalGraphQL(input interface{}) error {
    switch input := input.(type) {
        case map[string]interface{}:
            data, err := json.Marshal(input)
            if err != nil {
                u = &GraphQLUpload{}
            } else {
                json.Unmarshal(data, u)
            }
        
            return nil

        default:
            return errors.New("Cannot unmarshal received type as a GraphQLUpload type")
    }
}

Nous sommes désormais prêt à recevoir notre fichier en entrée, passons au middleware côté serveur.

Middleware GraphQL HTTP

Cette étape conciste à récupérer les données mutlipart/form-data et de transformer les fichiers et données POST reçues en entrée GraphQL afin que la requête se déroule correctement. Je ne vais pas détailler le code du middleware ici mais simplement décrire son fonctionnement.

Une requête HTTP d'envoi d'un fichier avec une donnée additionnelle (ici un titre) sera effectuée sous la forme suivante :

$ curl http://localhost:8000/graphql \
-F operations='{ "query": "mutation DoUpload($file: Upload!, $title: String!) { upload(file: $file, title: $title) }",
"variables": { "file": null, "title": null } }' \
-F map='{ "file": ["variables.file"], "title": ["variables.title"] }' \
-F file=@myfile.txt \
-F title="My content title"

La requête HTTP (de type multipart/form-data) sur notre serveur GraphQL reçoit plusieurs informations :

  • operations : il s'agit ici du contenu de la requête GraphQL : nous appelons ici la mutation upload(file: Upload!, title: String!) déclarée au début de cet article,
  • map : cette entrée est très importante, elle va nous permettre de savoir quelle donnée reçue correspond à quelle variable dans la requête GraphQL,
  • file et title : nos deux données.

Toute la magie correspond ici à re-mapper les données de formulaire reçues et spécifiées dans l'entrée map dans les operations reçues et plus particulièrement en utilisant les variables.

Ainsi, une fois ces opérations effectuées, le middleware re-forme la requête HTTP avec les paramètres corrects et renvoie le tout au serveur GraphQL (en JSON) :

graphqlParams := graphqlParams{
    Variables: operations["variables"],
    Query: operations["query"],
    Operations: operations,
    Map: mapEntries,
}

body, err := json.Marshal(graphqlParams)
if err == nil {
    r.Body = ioutil.NopCloser(bytes.NewReader(body))
    w.Header().Set("Content-Type", "application/json")
}

next.ServeHTTP(w, r)

Implémenter le middleware librairie graphql-go-upload

Le code du middleware est disponible sur GitHub ici : https://github.com/eko/graphql-go-upload. Afin de déclarer le middleware en amont de votre serveur GraphQL, vous n'avez qu'à importer la librairie et définir le middleware de la façon suivante :

import (
    "github.com/eko/graphql-go-upload"
)

// ...
h := handler.GraphQL{
    Schema: graphql.MustParseSchema(schema.String(), root, graphql.MaxParallelism(maxParallelism),
    graphql.MaxDepth(maxDepth)),
    Handler: handler.NewHandler(conf, &m),
}

mux := mux.NewRouter()
mux.Handle("/graphql", upload.Handler(h)) // Add the middleware here (wrap the original handler)

s := &http.Server{
    Addr: ":8000",
    Handler: mux,
}

Conclusion

Il est finalement simple de permettre à votre serveur GraphQL d'intercepter des requêtes multipart/form-data afin de pouvoir envoyer des fichiers et/ou des données de formulaire. Cette implémentation est également disponible sur NodeJS via Apollo server.

N'hésitez pas à me contacter si vous souhaitez avoir plus de détail sur l'implémentation ou l'utilisation de cette librairie.