Net-Base Magasin

27.05.2026

Multipart/Form-Data-upload i Delphi: robuste streams, boundary-kontrol og fejlfinding uden gætterier

Multipart/Form-Data-uploads virker trivielle, men i Delphi kan de hurtigt svigte ved håndtering af streams, filnavne, Content-Type, boundary-håndtering og timeouts. Dette kildekodeudsnit viser en robust, debugbar implementering med THTTPClient – inkl. korrekt beregnet Content-Length...

27.05.2026

Warum Multipart in Delphi oft erst im Betrieb „kaputtgeht“

Ein Multipart/Form-Data Upload in Delphi ist schnell zusammengeklickt – und scheitert dann in realen Integrationen an Details: falscher Content-Type pro Part, ein Boundary-String, der versehentlich im Payload vorkommt, unpassende Zeilenumbrüche, nicht-ASCII-Dateinamen oder Server, die chunked transfer encoding (HTTP ohne Content-Length) ablehnen. Dazu kommen typische Praxisprobleme in individueller Unternehmenssoftware: große Dateien (CAD, PDFs, Scans), schwankende Netze, Reverse-Proxies, strikte API-Gateways und Admin-Anforderungen an Debugging.

Delphi bringt mit System.Net.HttpClient einen brauchbaren Stack mit, aber die „Happy Path“-Beispiele lassen wichtige Randbedingungen offen. Der folgende Source-Schnipsel geht bewusst tiefer: Wir bauen Multipart als Stream deterministisch auf, berechnen Content-Length korrekt, unterstützen RFC-5987 für Dateinamen und liefern eine Debug-Option, die den Request reproduzierbar macht, ohne dass Sie TLS aufbrechen müssen.

Architekturentscheidung: THTTPClient statt Indy – und wann das kippt

THTTPClient (System.Net) nutzt je nach Plattform unterschiedliche Backends (unter Windows typischerweise WinHTTP/WinINet). Das ist für Unternehmensumgebungen oft vorteilhaft: Proxy- und TLS-Policies sind eher kompatibel mit dem System. Indy ist dafür sehr transparent und anpassbar, bringt aber eigene TLS-Bindings und ist im Betrieb manchmal „separat zu pflegen“ (OpenSSL-Versionen, Cipher-Suiten).

Der Ansatz hier nutzt THTTPClient, weil er in Modernisierungen häufig schon im Einsatz ist (REST-Client, OAuth, Downloads). Wenn Sie jedoch harte Kontrolle über TLS-Handshakes, Client-Zertifikate in Sonderformen oder sehr spezielle Proxy-Ketten benötigen, kann Indy (oder ein dedizierter HTTP-Stack) sinnvoll sein. Das ändert am Multipart-Aufbau wenig – aber an Debugging und Betrieb.

Multipart/Form-Data Upload in Delphi: ein Stream, keine Magie

Die Kernidee: Multipart ist am Ende nur ein Byte-Stream. Wenn wir ihn selbst aufbauen, können wir:

  • Boundary bewusst wählen und stabil testen
  • Header pro Part korrekt setzen (inkl. Content-Disposition, Content-Type)
  • Content-Length zuverlässig berechnen (wichtig für Server ohne Chunked-Support)
  • große Dateien streamen, ohne alles im RAM zu halten

Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen

Der Builder unten erzeugt wahlweise einen rein speicherbasierten Body (für kleine Uploads) oder eine Spool-Datei auf Disk (für große Payloads). Das wirkt „oldschool“, ist aber im Betrieb extrem praktisch, weil es Chunked vermeidet und Debugging erleichtert. Spoolen heißt: Sie können denselben Request-Body wiederverwenden, auch wenn ein Retry nötig ist.

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

    // Bygger den komplette body i en stream. Hvis ASpoolToFile er tom,
    // anvendes en TMemoryStream; ellers oprettes en fil.
    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
  // Boundary bør være tilstrækkelig tilfældig. Vigtigt: ingen mellemrum.
  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-headers er ASCII. For værdier i body'en (f.eks. UTF-8) sætter vi Content-Type pr. part.
  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''..." er betydeligt mere robust for ikke-ASCII-filnavne end kun 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 må ikke være nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // tilladt, men ofte en fejl: tom fil

  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
    // Advarsel: streamens position forbruges.
    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
        // To filnavneparametre: filename (til ældre servere) og 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);

        // Vigtigt: sæt positionen til starten, ellers uploades kun RESTer.
        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.

Hvad koden bevidst gør anderledes

  • Ingen „automatisches Multipart“: Kontrollen over headers, kodninger og boundary forbliver hos dig. Det er ofte afgørende ved strenge REST-APIs.
  • RFC-5987-understøttelse via filename*: Når filnavne indeholder umlauter (f.eks. „Prüfbericht.pdf“), er det den hyppigste interoperabilitetsfejl. Nogle servere ignorerer filename*, så filename fungerer som fallback.
  • Spool-to-File som driftsfeature: For store uploads og retries er en genanvendelig body-stream meget værdifuld.
  • Content-Length er tilgængelig, fordi body genereres på forhånd. Det undgår chunked-encoding, hvis målsystemet ikke accepterer det.

Afsendelse af request: Timeouts, Header og en fornuftig Retry-strategi

Multipart i sig selv løser ikke integrationsproblemerne: I har brug for timeouts, fejlkategorisering og eventuelt retries. Vigtigt er adskillelsen mellem idempotent og ikke idempotent: Uploads er ofte ikke idempotente (dubletter kan forekomme). Retries bør derfor kun foretages, hvis serveren tilbyder idempotent semantik (f.eks. Upload-ID, dedikeret Idempotency-Key-header) eller hvis I har deduplikation på serversiden.

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;

Faldgruber i praksis

  • Stream-position: Hvis FileStream ikke står i position 0, uploader I kun resten. Derfor tvinges Seek(0) i builderen.
  • Chunked vs. Content-Length: Nogle gateways (eller ældre server-stacks) afviser chunked. Det er et hyppigt legacy-tilfælde i processnære softwareløsninger. Spool-to-File er da en pragmatisk løsning.
  • CRLF: Multipart forventer CRLF (#13#10), ikke kun LF. Nogle servere er tolerante, andre ikke.
  • Content-Type pr. fil: Hvis I generelt sender application/octet-stream, er det ofte OK. Hvis serveren validerer (f.eks. PDF), angiv korrekt. I Delphi kan I løse MIME-mapping via egen tabel eller OS-funktioner, men stol ikke blindt på filendelser.

Debugging: reproducerbar Wire-Dump uden TLS-opbrud

Ved HTTPS kan du ikke se body’en i proxien, hvis du ikke må anvende en MitM (f.eks. et Fiddler-certifikat). Det er normalt i virksomhedsmiljøer. Builderen hjælper, fordi du har hele body’en som en stream og (ved spool-fil) som en fil.

Anbefalet fremgangsmåde:

  1. Skriv spool-body’en til en midlertidig fil.
  2. Log Content-Type inkl. Boundary og Content-Length.
  3. Opret eventuelt en curl-reproducerbar til Support/DevOps: Her behøver du ikke gengive body’en 1:1, men du kan spejle parametre og fil(er).

Vigtigt: Log aldrig produktive tokens eller personoplysninger. I mange integrationer med forretningssoftware er netop det den compliance-relevante del.

Varianter: flere filer, valgfrie felter, servere med „specielle“ forventninger

Flere filer under samme feltnavn

Mange API’er forventer files[] eller gentagne samme navn. Builderen understøtter dette direkte: Kald AddFile flere gange med samme FieldName. Om du bruger files, files[] eller attachments er udelukkende serverkonvention.

Server kræver præcis application/json som ekstra part

Et udbredt mønster: En JSON-metadatalblok plus en fil. Så sender du JSON’en som et field-part, men med Content-Type: application/json; charset=utf-8. Det er ikke et „form field“ i UI-forstand, men det kan repræsenteres korrekt i multipart:

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

Legacy: Server accepterer kun filename, ikke filename*

Så hjælper fallback via filename. Hvis serveren dog fejldekoder ikke-ASCII i filename, er en robust løsning ofte kun at ignorere filnavnet på serversiden og i stedet vedlægge et ekstra felt originalName i JSON’en.

Kontext for modernisering og drift

I voksede Delphi-landskaber hænger multipart ofte i periferien: et interface til DMS, arkiv, ticketing, Kundeportal eller en intern REST-server. Her opstår præcis det pres fra nye sikkerhedskrav (TLS, Gateways, Proxies) og fra større filstørrelser.

Den beskrevne tilgang er særligt relevant, når:

  • du skal kunne debugge uploads reproducerbart (drift/administration)
  • du vil/er nødt til at undgå chunked
  • filnavne/encodings rent faktisk forekommer (Umlaute, mellemrum, parenteser)
  • Retry/Idempotency bør være løst konceptuelt og entydigt

Den er mindre relevant, hvis du udelukkende sender små filer til en tolerant server og ikke har brug for driftstransparens. Så er en enkel high-level-løsning tilstrækkelig – indtil den første „specielle“ fil fra fagafdelingen dukker op.

Konklusion: Stabil multipart-upload er et streaming- og driftsproblem

Et korrekt multipart/form-data-upload i Delphi er mindre et spørgsmål om „hvilken komponent“ end om kontrol: Boundary, CRLF, filnavn, Content-Type og frem for alt en deterministisk body-stream. Den, der bygger det rigtigt fra starten, sparer senere tid i debug-sløjfer med API-Gateways og Reverse-Proxies.

Anvendelsesgrænse for tilgangen: Hvis der skal uploades ekstremt store filer (flere GB) uden spooling og uden Content-Length, bliver emnet Streaming uden forudberegning relevant – så skal målservere og infrastruktur pålideligt understøtte Chunked, og der er behov for et andet debugging-koncept. For mange integrationer i digitale virksomheds­løsninger er den her viste Builder dog netop den pragmatiske midte mellem robusthed, efterprøvelighed og kontrolleret ressourceforbrug.

Hvis I er bundet til en etableret Delphi-integration, hvor uploads sporadisk fejler eller kun „for nogle filer“, er det som regel en indikator for netop disse randbetingelser. For målrettet støtte ved analyse, modernisering eller afklaring af drift kan I kontakte os her:

I det faglige miljø spiller også Delphi Thttpclient og REST API filupload en vigtig rolle, når integrationer, dataflows og videreudvikling skal fungere pålideligt sammen.

Drøft projekt eller moderniseringsprojekt med Net-Base.

Del indlæg

Del dette indlæg direkte

LinkedIn, X, XING, Facebook, WhatsApp og e-mail er straks tilgængelige. Til Instagram forbereder vi link og kort tekst med det samme.

E-mail

Instagram åbner i en ny fane. Linket og kortteksten kopieres på forhånd til udklipsholderen.