Net-Base Magazine

27.05.2026

Téléversement Multipart/Form-Data dans Delphi : flux robustes, contrôle des délimiteurs (boundary) et débogage sans tâtonnements

Les uploads Multipart/Form-Data semblent triviaux, mais dans Delphi ils déraillent rapidement dès qu’il s’agit de flux, de noms de fichier, du Content-Type, de la gestion des délimiteurs (boundary) et des timeouts. Cet extrait de code montre une implémentation robuste et débogable avec THTTPClient — incluant le calcul correct du Content-Length...

27.05.2026

Pourquoi le Multipart dans Delphi pose souvent des problèmes uniquement en exploitation

Un upload Multipart/Form-Data dans Delphi se construit rapidement à la souris — et échoue ensuite dans des intégrations réelles sur des détails : mauvais Content-Type par part, une chaîne de boundary qui apparaît accidentellement dans le payload, des retours à la ligne inappropriés, des noms de fichiers non-ASCII ou des serveurs qui refusent le chunked transfer encoding (HTTP sans Content-Length). S’y ajoutent des problèmes pratiques typiques des logiciels d’entreprise sur-mesure : fichiers volumineux (CAO, PDF, scans), réseaux instables, reverse-proxies, API-Gateways stricts et exigences administratives pour le débogage.

Delphi fournit avec System.Net.HttpClient une pile utilisable, mais les exemples « happy path » laissent de côté des conditions limites importantes. L’extrait de code suivant va volontairement plus loin : nous construisons le Multipart comme un flux déterministe, calculons correctement la Content-Length, prenons en charge RFC-5987 pour les noms de fichiers et proposons une option de debug qui rend la requête reproductible sans devoir casser TLS.

Décision d’architecture : THTTPClient plutôt qu’Indy — et quand cela bascule

THTTPClient (System.Net) utilise selon la plateforme des backends différents (sous Windows typiquement WinHTTP/WinINet). C’est souvent avantageux en contexte d’entreprise : les politiques proxy et TLS sont en général plus compatibles avec celles du système. Indy est en revanche très transparent et configurable, mais apporte ses propres bindings TLS et nécessite parfois une maintenance « séparée » en production (versions d’OpenSSL, suites de chiffrement).

L’approche présentée ici utilise THTTPClient parce qu’il est fréquemment déjà déployé lors de modernisations (REST-Client, OAuth, téléchargements). Si vous avez en revanche besoin d’un contrôle strict des handshakes TLS, de certificats clients dans des formes particulières ou de chaînes de proxy très spécifiques, Indy (ou une pile HTTP dédiée) peut être plus adaptée. Cela change peu la construction du Multipart — mais impacte le débogage et l’exploitation.

Multipart/Form-Data Upload dans Delphi : un flux, pas de la magie

L’idée centrale : au final, le Multipart n’est qu’un flux d’octets. Si nous le construisons nous-mêmes, nous pouvons :

  • choisir le boundary de manière contrôlée et le tester de façon stable
  • définir correctement les en-têtes par part (y compris Content-Disposition, Content-Type)
  • calculer la Content-Length de façon fiable (important pour les serveurs sans support de chunked)
  • streamer les fichiers volumineux sans tout charger en RAM

Le code : Multipart-Builder avec streaming et noms de fichiers RFC-5987

Le builder ci‑dessous génère soit un body entièrement en mémoire (pour de petits uploads) soit un fichier de spool sur disque (pour de grosses payloads). Cela peut paraître « oldschool », mais c’est extrêmement pratique en exploitation, car cela évite le chunked et facilite le débogage. Le spooling signifie : vous pouvez réutiliser le même body de requête, même si un retry est nécessaire.

Delphi
unit Net-Base.Multipart;

interface

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  System.NetEncoding, System.Hash;

type
  TMultipartFormData = class
  private
    FBoundary: string;
    FParts: TObjectList<TObject>;
    function CRLF: TBytes;
    function BytesOfAscii(const S: string): TBytes;
    function Quote(const S: string): string;
    function Rfc5987FileNameStar(const FileName: string): string;
    function NewBoundary: string;
  public
    constructor Create(const ABoundary: string = '');
    destructor Destroy; override;

    function ContentType: string;
    function Boundary: string;

    procedure AddField(const Name, Value: string; const ContentType: string = 'text/plain; charset=utf-8');
    procedure AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);

    // Construit le corps complet dans un flux. Si ASpoolToFile est vide,
    // un TMemoryStream est utilisé ; sinon un fichier est créé.
    function BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string = ''): TStream;
  end;

implementation

uses
  System.Generics.Collections;

type
  TPartKind = (pkField, pkFile);

  TPart = class
  public
    Kind: TPartKind;
    Name: string;
    ContentType: string;
    // Field
    Value: string;
    // File
    FileName: string;
    FileStream: TStream;
    constructor Create; 
  end;

constructor TPart.Create;
begin
  inherited Create;
end;

constructor TMultipartFormData.Create(const ABoundary: string);
begin
  inherited Create;
  FParts := TObjectList<TObject>.Create(True);
  if ABoundary <> '' then
    FBoundary := ABoundary
  else
    FBoundary := NewBoundary;
end;

destructor TMultipartFormData.Destroy;
begin
  FParts.Free;
  inherited;
end;

function TMultipartFormData.NewBoundary: string;
var
  R: TBytes;
begin
  // Le boundary doit être suffisamment aléatoire. Important : pas d'espaces.
  SetLength(R, 16);
  TNetEncoding.Base64.Decode(TNetEncoding.Base64.EncodeBytesToBytes(THashSHA2.GetHashBytes(GuidToString(TGuid.NewGuid))), R);
  Result := '----DelphiBoundary' + THashSHA2.GetHashString(GuidToString(TGuid.NewGuid));
  Result := Result.Replace('{','').Replace('}','').Replace('-','');
end;

function TMultipartFormData.Boundary: string;
begin
  Result := FBoundary;
end;

function TMultipartFormData.ContentType: string;
begin
  Result := 'multipart/form-data; boundary=' + FBoundary;
end;

function TMultipartFormData.CRLF: TBytes;
begin
  Result := TBytes.Create($0D, $0A);
end;

function TMultipartFormData.BytesOfAscii(const S: string): TBytes;
begin
  // Les en-têtes multipart sont en ASCII. Pour les valeurs dans le corps (p.ex. UTF-8), nous définissons le Content-Type par partie.
  Result := TEncoding.ASCII.GetBytes(S);
end;

function TMultipartFormData.Quote(const S: string): string;
begin
  Result := '"' + S.Replace('"', '"') + '"';
end;

function TMultipartFormData.Rfc5987FileNameStar(const FileName: string): string;
var
  Utf8: TBytes;
  Enc: string;
begin
  // filename*="UTF-8''..." est nettement plus robuste pour les noms de fichier non ASCII que filename="...".
  Utf8 := TEncoding.UTF8.GetBytes(FileName);
  Enc := TNetEncoding.URL.EncodeBytesToString(Utf8);
  Result := 'filename*=' + 'UTF-8'''''+ Enc;
end;

procedure TMultipartFormData.AddField(const Name, Value: string; const ContentType: string);
var
  P: TPart;
begin
  P := TPart.Create;
  P.Kind := pkField;
  P.Name := Name;
  P.Value := Value;
  P.ContentType := ContentType;
  FParts.Add(P);
end;

procedure TMultipartFormData.AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);
var
  P: TPart;
begin
  if FileStream = nil then
    raise EArgumentNilException.Create('FileStream ne doit pas être nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // autorisé, mais souvent une erreur : fichier vide

  P := TPart.Create;
  P.Kind := pkFile;
  P.Name := FieldName;
  P.FileName := FileName;
  P.ContentType := ContentType;
  P.FileStream := FileStream; // Owner bleibt beim Aufrufer
  FParts.Add(P);
end;

function TMultipartFormData.BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string): TStream;
var
  OutStream: TStream;
  WriterUtf8: TBytes;
  PartObj: TObject;
  P: TPart;
  Header: string;
  Sep, EndSep: string;
  B: TBytes;

  procedure WriteAscii(const S: string);
  begin
    B := BytesOfAscii(S);
    OutStream.WriteBuffer(B, Length(B));
  end;

  procedure WriteBytes(const Bytes: TBytes);
  begin
    if Length(Bytes) > 0 then
      OutStream.WriteBuffer(Bytes, Length(Bytes));
  end;

  procedure CopyStreamFully(Src: TStream);
  var
    Buf: array[0..64*1024-1] of Byte;
    ReadN: Integer;
  begin
    // Attention : la position du flux est consommée.
    while True do
    begin
      ReadN := Src.Read(Buf, SizeOf(Buf));
      if ReadN <= 0 then
        Break;
      OutStream.WriteBuffer(Buf, ReadN);
    end;
  end;

begin
  if ASpoolToFile <> '' then
    OutStream := TFileStream.Create(ASpoolToFile, fmCreate or fmShareDenyWrite)
  else
    OutStream := TMemoryStream.Create;

  try
    Sep := '--' + FBoundary + #13#10;
    EndSep := '--' + FBoundary + '--' + #13#10;

    for PartObj in FParts do
    begin
      P := TPart(PartObj);

      WriteAscii(Sep);

      if P.Kind = pkField then
      begin
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  #13#10;
        WriteAscii(Header);
        // Field-Body in UTF-8, sofern charset=utf-8 gesetzt ist.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Deux paramètres filename : filename (pour anciens serveurs) et filename* (RFC 5987)
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + '; ' +
                  'filename=' + Quote(ExtractFileName(P.FileName)) + '; ' +
                  Rfc5987FileNameStar(ExtractFileName(P.FileName)) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  'Content-Transfer-Encoding: binary' + #13#10 +
                  #13#10;
        WriteAscii(Header);

        // Important : positionner au début, sinon seuls des RESTes seront téléchargés.
        if P.FileStream.Seek(0, soBeginning) <> 0 then
          ;
        CopyStreamFully(P.FileStream);
        WriteBytes(CRLF);
      end;
    end;

    WriteAscii(EndSep);

    AContentLength := OutStream.Size;
    OutStream.Position := 0;
    Result := OutStream;
  except
    OutStream.Free;
    raise;
  end;
end;

end.

Ce que le code fait délibérément différemment

  • Pas de « multipart automatique » : Le contrôle des en-têtes, des encodages et de la boundary reste entre vos mains. C’est souvent crucial pour les API strictes REST.
  • Prise en charge de RFC-5987 via filename* : Dès que les noms de fichiers contiennent des caractères accentués (p. ex. « Prüfbericht.pdf »), c’est le bogue d’interopérabilité le plus fréquent. Certains serveurs ignorent filename*, alors filename sert de repli.
  • Spool-to-File comme fonctionnalité d’exploitation : pour les uploads volumineux et les tentatives de réessai, un flux de corps réutilisable est d’une grande utilité.
  • Content-Length est disponible, car le body est généré au préalable. Cela évite le Chunked-Encoding si le système cible ne l’accepte pas.

Envoi de la requête : Timeouts, en-têtes et une stratégie de réessai pertinente

Le multipart lui-même ne résout pas encore les problèmes d’intégration : vous avez besoin de timeouts, de classification des erreurs et éventuellement de réessais. Il est important de distinguer entre idempotent et nicht idempotent : les uploads ne sont souvent pas idempotents (doublons possibles). Les réessais ne doivent donc avoir lieu que si le serveur propose une sémantique idempotente (p. ex. Upload-ID, en-tête dédié Idempotency-Key) ou si vous disposez d’une déduplication côté serveur.

Delphi
uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  Net-Base.Multipart;

function PostMultipart(const Url: string; const Token: string; const MP: TMultipartFormData;
  const SpoolFile: string = ''): IHTTPResponse;
var
  Client: THTTPClient;
  Body: TStream;
  ContentLen: Int64;
  Req: IHTTPRequest;
begin
  Client := THTTPClient.Create;
  try
    // Timeouts: je nach Datei und Leitung realistisch setzen.
    Client.ConnectionTimeout := 15000; // ms
    Client.ResponseTimeout := 600000;  // ms (10 min)

    Body := MP.BuildBodyStream(ContentLen, SpoolFile);
    try
      Req := Client.GetRequest('POST', Url);
      Req.SourceStream := Body;

      Req.AddHeader('Content-Type', MP.ContentType);
      // Manche Server oder Proxies erwarten Content-Length zwingend.
      Req.AddHeader('Content-Length', ContentLen.ToString);

      if Token <> '' then
        Req.AddHeader('Authorization', 'Bearer ' + Token);

      // Optional: wenn der Server sauber JSON liefert, kann Accept helfen.
      Req.AddHeader('Accept', 'application/json');

      Result := Client.Execute(Req, nil);
    finally
      Body.Free;
    end;
  finally
    Client.Free;
  end;
end;

Pièges rencontrés en pratique

  • Stream-Position : Si le FileStream n’est pas positionné à 0, vous n’uploadez que le reste. Le Builder force donc Seek(0).
  • Chunked vs. Content-Length : Certains gateways (ou piles serveur plus anciennes) rejettent le Chunked. C’est un cas legacy fréquent dans les solutions logicielles proches du processus. Spool-to-File est alors pragmatique.
  • CRLF : le multipart attend CRLF (#13#10), pas seulement LF. Certains serveurs sont tolérants, d’autres pas.
  • Content-Type par fichier : Si vous envoyez systématiquement application/octet-stream, c’est souvent acceptable. Si le serveur effectue une vérification (p. ex. PDF), définissez-le correctement. Dans Delphi vous pouvez résoudre le mapping MIME via votre propre table ou les fonctions OS, mais ne vous fiez pas aveuglément aux extensions de fichier.

Débogage : dump du trafic reproductible sans décryptage TLS

Avec HTTPS, vous ne voyez pas le body dans le proxy si vous n’êtes pas autorisé à utiliser un MitM (p. ex. le certificat Fiddler). C’est normal en environnement d’entreprise. Le Builder aide, car vous disposez du body complet en flux et (pour un fichier de spool) sous forme de fichier.

Procédure recommandée :

  1. Écrivez le body de spool dans un fichier temporaire.
  2. Consignez dans les logs le Content-Type incluant la boundary et le Content-Length.
  3. Générez, pour le Support/DevOps, facultativement une reproduction curl : vous n’êtes pas tenu de reproduire le body 1:1, mais vous pouvez refléter les paramètres et le(s) fichier(s).

Important : ne consignez jamais en log des tokens de production ni des données personnelles. Dans de nombreuses intégrations de logiciels métier, c’est précisément cet aspect qui relève de la conformité.

Variantes : plusieurs fichiers, champs optionnels, serveurs aux attentes « étranges »

Plusieurs fichiers avec le même nom de champ

Beaucoup d’API attendent files[] ou plusieurs occurrences du même nom. Le Builder prend cela en charge directement : appelez AddFile plusieurs fois avec le même FieldName. Que vous utilisiez files, files[] ou attachments est une convention côté serveur.

Serveur exige exactement „application/json“ comme part supplémentaire

Un schéma courant : un bloc de métadonnées JSON plus un fichier. Envoyez alors le JSON comme field-part, mais avec Content-Type: application/json; charset=utf-8. Ce n’est pas un « form field » au sens de l’interface utilisateur, mais c’est proprement représentable en multipart :

Delphi
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');

Legacy : le serveur n’accepte que filename, pas filename*

Alors le fallback via filename aide. Si le serveur décode incorrectement du non-ASCII dans filename, la solution la plus robuste est souvent : ignorer le nom de fichier côté serveur et envoyer à la place un champ additionnel originalName dans le JSON.

Positionnement pour la modernisation et l’exploitation

Dans des paysages Delphi existants, le multipart se situe souvent en périphérie : une interface vers un DMS, un archivage, du ticketing, le portail client ou un REST-serveur interne. C’est précisément là que se font sentir la pression des nouvelles exigences de sécurité (TLS, gateways, proxies) et des tailles de fichiers plus importantes.

L’approche présentée est particulièrement pertinente lorsque :

  • vous devez déboguer les uploads de manière reproductible (exploitation/administration)
  • vous souhaitez/devez éviter le chunked
  • les noms de fichiers/encodages apparaissent en pratique (caractères accentués (Umlaute), espaces, parenthèses)
  • le retry/l’idempotence doivent être conçus proprement

Elle est moins intéressante si vous envoyez exclusivement de petits fichiers à un serveur tolérant et n’avez aucune besoin de transparence opérationnelle. Dans ce cas, une solution haut niveau simple suffit — jusqu’à ce que le premier fichier « étrange » de la direction métier arrive.

Conclusion : un upload Multipart stable est un problème de streaming et d’exploitation

Un upload Multipart/Form-Data propre dans Delphi est moins une question de « quelle composante » que de contrôle : boundary, CRLF, nom de fichier, Content-Type et surtout un flux de body déterministe. Qui construit cela correctement dès le départ gagne ensuite du temps dans les boucles de débogage avec des API-Gateways et des reverse-proxies.

Limite d’application de l’approche : Si vous devez téléverser des fichiers extrêmement volumineux (plusieurs Go) sans spooling et sans Content-Length, la question du Streaming sans pré-calcul devient pertinente — dans ce cas, les serveurs cibles et l’infrastructure doivent prendre en charge Chunked de manière fiable, et vous avez besoin d’un concept de débogage différent. Pour de nombreuses intégrations dans des solutions d’entreprise numériques, le Builder présenté ici constitue toutefois le juste milieu pragmatique entre robustesse, traçabilité et consommation de ressources maîtrisable.

Si vous dépendez d’une intégration Delphi existante, pour laquelle les uploads échouent sporadiquement ou seulement « pour certains fichiers », c’est généralement un indicateur de ces conditions limites. Pour un support ciblé en matière d’analyse, de modernisation ou de clarification opérationnelle, contactez-nous ici :

Dans le domaine fonctionnel, Delphi Thttpclient et REST API de téléchargement de fichiers jouent également un rôle important, lorsque intégrations, flux de données et évolution doivent bien s’articuler.

Discuter d’un projet ou d’une modernisation avec Net-Base.

Partager l'article

Partager directement cette publication

LinkedIn, X, XING, Facebook, WhatsApp et e-mail sont immédiatement disponibles. Pour Instagram, nous préparons directement le lien et un court texte.

Courriel

Instagram s'ouvre dans un nouvel onglet. Le lien et le court texte sont préalablement copiés dans le presse-papiers.