Net-Base Magazín

27.05.2026

Multipart/Form-Data Upload v Delphi: robustní streamy, kontrola boundary a ladění bez hádání

Multipart/Form-Data uploady se zdají triviální, ale v Delphi rychle selhávají u streamů, názvů souborů, Content-Type, zpracování boundary a timeoutů. Tento ukázkový úryvek zdrojového kódu ukazuje robustní, laditelnou implementaci s THTTPClient – včetně správně spočítaného Content-Length...

27.05.2026

Proč se Multipart v Delphi často až v provozu „selže“

Jedno Multipart/Form-Data nahrání v Delphi se rychle poskládá – a v reálných integracích pak naráží na detaily: špatný Content-Type pro část, Boundary-řetězec, který se omylem vyskytne v payloadu, nevhodné konce řádků, názvy souborů s ne-ASCII znaky nebo servery, které odmítají chunked transfer encoding (HTTP bez Content-Length). K tomu typické provozní problémy v individuálním podnikových aplikacích: velké soubory (CAD, PDF, skeny), proměnlivé sítě, reverse proxy, přísné API brány a požadavky administrace na debugování.

Delphi přináší s System.Net.HttpClient použitelný stack, ale ukázky „Happy Path“ nechávají důležité okrajové podmínky stranou. Následující ukázka zdrojového kódu jde záměrně hlouběji: Multipart sestavíme jako proud (stream) deterministicky, počítáme Content-Length korektně, podporujeme RFC-5987 pro názvy souborů a dodáváme debugovací volbu, která požadavek reprodukuje bez toho, abyste museli prolomit TLS.

Rozhodnutí v architektuře: THTTPClient místo Indy – a kdy to přestává platit

THTTPClient (System.Net) používá v závislosti na platformě různá backendy (pod Windows typicky WinHTTP/WinINet). To je v podnikových prostředích často výhodné: proxy a TLS policy jsou obvykle více kompatibilní se systémem. Indy je naopak velmi transparentní a přizpůsobitelný, ale přináší vlastní TLS bindingy a je v provozu někdy „separátně k udržování“ (verze OpenSSL, sady šifer).

Přístup zde používá THTTPClient, protože je při modernizacích často již nasazen (REST-client, OAuth, stahování). Pokud ale potřebujete tvrdou kontrolu nad TLS handshake, klientské certifikáty ve speciálních formátech nebo velmi specifické proxy řetězce, může být Indy (nebo dedikovaný HTTP-stack) vhodnější. To na sestavení Multipartu příliš nemění – ovlivní to však debugování a provoz.

Multipart/Form-Data upload v Delphi: stream, žádná magie

Jádrem myšlenky je, že Multipart je nakonec jen byte-stream. Když ho sestavíme sami, můžeme:

  • Boundary vědomě zvolit a stabilně testovat
  • hlavičky pro jednotlivé části správně nastavit (včetně Content-Disposition, Content-Type)
  • spolehlivě spočítat Content-Length (důležité pro servery bez podpory chunked)
  • streamovat velké soubory, aniž bychom drželi vše v RAM

Kód: Multipart-Builder se streamováním a názvy souborů podle RFC-5987

Builder níže vytváří buď paměťově založené tělo (pro malé uploady), nebo spool-soubor na disku (pro velké payloady). Působí to „oldschool“, ale v provozu je to extrémně praktické, protože to eliminuje chunked a usnadňuje debugování. Spoolování znamená: můžete znovu použít stejný request-body i při nutnosti opakování (retry).

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);

    // Sestaví celý body do streamu. Pokud je ASpoolToFile prázdné,
    // použije se TMemoryStream; jinak se vytvoří soubor.
    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;
    // Pole
    Value: string;
    // Soubor
    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
  // Boundary by mělo být dostatečně náhodné. Důležité: žádné mezery.
  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
  // Multipart hlavičky jsou v ASCII. Pro hodnoty v těle (např. UTF-8) nastavujeme pro každý part Content-Type.
  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''..." je pro názvy souborů s ne-ASCII znaky výrazně robustnější než pouhé 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 nesmí být nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // povoleno, ale často chyba: prázdný soubor

  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
    // Pozor: pozice ve streamu bude posunuta.
    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
        // Dva parametry názvu souboru: filename (pro staré servery) a 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);

        // Důležité: nastavit pozici na začátek, jinak se nahrají pouze zbytky.
        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.

Co kód záměrně dělá jinak

  • Žádné „automatické Multipart“: Kontrola nad hlavičkami, kódováním a boundary zůstává u vás. To je u přísných REST-API často rozhodující.
  • Podpora RFC-5987 přes filename*: Jakmile názvy souborů obsahují diakritiku (např. „Prüfbericht.pdf“), jde o nejčastější chybu při interoperabilitě. Některé servery ignorují filename*, v takovém případě se použije filename jako fallback.
  • Spool-to-File jako provozní funkce: Pro velké nahrávání a opakované pokusy o odeslání je znovupoužitelný stream těla mimořádně cenný.
  • Content-Length je dostupný, protože tělo se vygeneruje předem. To zabrání Chunked-Encoding, pokud cílový systém tento režim nepřijímá.

Odeslání požadavku: timeouty, hlavičky a smysluplná strategie opakování

Multipart samo o sobě neřeší integrační problémy: potřebujete timeouty, klasifikaci chyb a volitelné opakování pokusů. Důležité je rozlišit mezi idempotentní a ne-idempotentní: nahrávání souborů často není idempotentní (mohou vzniknout duplicity). Opakování pokusů by proto mělo probíhat pouze, pokud server poskytuje idempotentní sémantiku (např. Upload-ID, dedikovaný Idempotency-Key header) nebo pokud máte na serveru deduplikaci.

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;

Úskalí v praxi

  • Pozice streamu: Pokud FileStream není na pozici 0, nahrajete pouze zbytek. V builderu se proto vynucuje Seek(0).
  • Chunked vs. Content-Length: Některé brány (nebo starší serverové stacky) Chunked odmítají. To je častý legacy případ v procesně orientovaných softwarových řešeních. Spool-to-File je v takovém případě pragmatické řešení.
  • CRLF: Multipart očekává CRLF (#13#10), ne pouze LF. Některé servery jsou tolerantní, jiné ne.
  • Content-Type pro soubor: Pokud posíláte paušálně application/octet-stream, je to často v pořádku. Pokud server provádí kontrolu (např. PDF), nastavte ho správně. V Delphi můžete řešit mapování MIME pomocí vlastní tabulky nebo funkcí OS, ale nespoléhejte se slepě na přípony souborů.

Debugging: reprodukovatelný Wire-Dump bez narušení TLS

Při HTTPS nevidíte tělo požadavku v proxy, pokud nesmíte používat MitM (např. Fiddler‑certifikát). To je v podnikových prostředích běžné. Der Builder pomáhá, protože máte celý body streamově k dispozici a (u spoolového souboru) také jako soubor.

Osvědčený postup:

  1. Zapište spool‑tělo do dočasného souboru.
  2. Zalogujte Content-Type včetně boundary a Content-Length.
  3. Vytvořte pro support/DevOps volitelně curl-repro: Není nutné tělo 1:1 reprodukovat, ale můžete zrcadlit parametry a soubor(y).

Důležité: Nikdy nezalogujte produkční tokeny ani osobní údaje. V mnoha integračních scénářích podnikového softwaru jde právě o ten compliance‑relevantní aspekt.

Varianty: více souborů, volitelná pole, servery s „podivnými“ očekáváními

Více souborů pod stejným názvem pole

Mnoho API očekává files[] nebo opakovaně stejný název pole. Der Builder to podporuje přímo: volejte AddFile opakovaně se stejným FieldName. Zda použijete files, files[] nebo attachments, je čistě serverová konvence.

Server vyžaduje přesně „application/json“ jako dodatečnou část

Běžný scénář: JSON blok metadat plus soubor. JSON tedy pošlete jako field‑part, ale s Content-Type: application/json; charset=utf-8. To není „form field“ ve smyslu UI, ale v multipartu se to dá čistě vyjádřit:

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

Legacy: Server akceptuje pouze filename, ne filename*

Pak pomůže fallback přes filename. Pokud však server ne‑ASCII v filename špatně dekóduje, jako robustní řešení často zůstává: ignorovat název souboru na serveru a místo toho posílat další pole originalName v JSONu.

Zařazení pro modernizaci a provoz

V rostoucích Delphi‑landskapích leží multipart často na okraji: rozhraní k DMS, archivu, ticketingu, zákaznické portály nebo interní REST-Server. Právě tam roste tlak kvůli novým bezpečnostním požadavkům (TLS, gatewaye, proxy) a kvůli větším velikostem souborů.

Představený přístup se vyplatí zejména, pokud:

  • musíte nahrávání reprodukovatelně ladit (provoz/administrace)
  • chcete/musíte vyhnout se chunked režimu
  • se v praxi opravdu objevují problémy s názvy souborů/kódováním (umlauty, mezery, závorky)
  • má být retry/idempotency konceptuálně čistě řešeno

Nevyplatí se tolik, pokud posíláte výhradně malé soubory na tolerantní server a nepotřebujete žádnou provozní transparentnost. Pak stačí jednoduché high‑level řešení – dokud nepřijde první „podivný“ soubor z obchodního oddělení.

Závěr: Stabilní Multipart‑Upload je streamovací a provozní problém

Čistý Multipart/Form‑Data upload v Delphi je méně otázkou „které komponenty“ a více otázkou kontroly: boundary, CRLF, název souboru, Content‑Type a především deterministický body‑stream. Kdo to postaví správně hned, ušetří později čas v debugovacích smyčkách s API‑gatewayi a reverse‑proxy.

Meze použitelnosti přístupu: Pokud musíte nahrávat extrémně velké soubory (několik GB) bez spooling a bez Content-Length, stává se relevantním téma streamingu bez předběžného výpočtu – v takovém případě musí cílové servery a infrastruktura spolehlivě podporovat chunked a potřebujete jiné koncepce ladění. Pro mnoho integrací v digitálních podnikových řešeních je však zde ukázaný Builder právě pragmatickým středem mezi robustností, sledovatelností a kontrolovatelnou spotřebou zdrojů.

Pokud máte existující Delphi-integraci, u které nahrávání příležitostně selhává nebo pouze „u některých souborů“, je to obvykle indikátor právě těchto okrajových podmínek. Pro cílenou podporu při analýze, modernizaci nebo objasnění provozu nás můžete kontaktovat zde:

V odborném kontextu hrají také Delphi Thttpclient a REST API nahrávání souborů důležitou roli, pokud integrace, tok dat a další vývoj musí bezproblémově spolupracovat.

Projednat projekt nebo modernizační záměr s Net-Base.

Sdílet příspěvek

Sdílet tento příspěvek přímo

LinkedIn, X, XING, Facebook, WhatsApp a e-mail jsou ihned k dispozici. Pro Instagram připravíme odkaz a stručný text.

E-mail

Instagram se otevře v nové záložce. Odkaz a krátký text budou předtím zkopírovány do schránky.