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-Lengthde 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.
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 ignorentfilename*, alorsfilenamesert 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.
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 :
- Écrivez le body de spool dans un fichier temporaire.
- Consignez dans les logs le
Content-Typeincluant la boundary et leContent-Length. - 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 :
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.