Varför Multipart i Delphi ofta först i drift „går sönder“
En Multipart/Form-Data-upload i Delphi klickas snabbt ihop – och misslyckas sedan i verkliga integrationer på detaljer: felaktig Content-Type per part, en boundary-sträng som av misstag förekommer i payloaden, olämpliga radbrytningar, icke-ASCII-filnamn eller servrar som chunked transfer encoding (HTTP utan Content-Length) avvisar. Därtill kommer typiska praktiska problem i individuell företagsmjukvara: stora filer (CAD, PDFs, skanningar), varierande nät, reverse-proxies, strikta API-gateways och adminkrav på debuggningsmöjligheter.
Delphi levererar med System.Net.HttpClient en användbar stack, men de „happy path“-exemplen lämnar viktiga randvillkor öppna. Följande källkodssnutt går medvetet djupare: vi bygger upp Multipart som en ström deterministiskt, beräknar Content-Length korrekt, stödjer RFC-5987 för filnamn och tillhandahåller en debug-option som gör förfrågan reproducerbar utan att du behöver bryta upp TLS.
Arkitekturval: THTTPClient istället för Indy – och när det fallerar
THTTPClient (System.Net) använder beroende på plattform olika backend (under Windows typiskt WinHTTP/WinINet). Det är ofta fördelaktigt i företagsmiljöer: proxy- och TLS-policys är mer kompatibla med systemet. Indy är däremot mycket transparent och anpassningsbart, men introducerar egna TLS-bindningar och måste ibland i drift „underhållas separat“ (OpenSSL-versioner, cipher-sviter).
Detta tillvägagångssätt använder THTTPClient eftersom det i moderniseringar ofta redan är i bruk (REST-Client, OAuth, nedladdningar). Om ni däremot behöver hård kontroll över TLS-handshakes, klientcertifikat i specialformer eller mycket specifika proxy-kedjor kan Indy (eller en dedikerad HTTP-stack) vara lämpligt. Det ändrar lite i hur man bygger Multipart – men påverkar debuggnings- och driftaspekterna.
Multipart/Form-Data-upload i Delphi: en ström, ingen magi
Kärnidén: Multipart är i slutändan bara en byte-ström. Om vi bygger upp den själva kan vi:
- Välja boundary medvetet och testa den grundligt
- Sätta header per part korrekt (inkl.
Content-Disposition,Content-Type) - Beräkna
Content-Lengthpålitligt (viktigt för servrar utan stöd för chunked) - Streama stora filer utan att hålla allt i RAM
Koden: Multipart-builder med streaming och RFC-5987-filnamn
Byggaren nedan skapar antingen en helt minnesbaserad body (för små uploads) eller en spool-fil på disk (för stora payloads). Det kan verka „oldschool“, men är i drift extremt praktiskt eftersom det undviker chunked och underlättar debugging. Att spoola innebär: du kan återanvända samma request-body även om en retry behövs.
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 upp hela body i en TStream. Om ASpoolToFile är tom används en TMemoryStream; annars skapas 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 vara tillräckligt slumpmässig. Viktigt: inga blanksteg.
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 är ASCII. För värden i body (t.ex. UTF-8) sätter vi Content-Type per del.
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''..." är för icke-ASCII-filnamn betydligt mer robust än enbart 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 får inte vara nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // tillåtet, men ofta ett fel: tom fil
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Ägarskapet förblir hos uppringaren
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
// Observera: streamens position förbrukas.
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);
// Fältets innehåll i UTF-8, om charset=utf-8 är satt.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Två filnamnsparametrar: filename (för gamla servrar) och 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);
// Viktigt: sätt positionen till början, annars laddas endast RESTer upp.
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.
Vad koden medvetet gör annorlunda
- Ingen „automatiskt multipart“: Kontrollen över Header, kodningar och boundary ligger kvar hos dig. Det är ofta avgörande för strikta REST-API:er.
- RFC-5987-stöd över
filename*: När filnamn innehåller diakritiska tecken (t.ex. „Prüfbericht.pdf“) är detta den vanligaste interoperabilitetsbuggen. Vissa servrar ignorerarfilename*, då användsfilenamesom fallback. - Spool-to-File som driftsfunktion: För stora uppladdningar och retries är en återanvändbar body-stream ovärderlig.
- Content-Length är tillgänglig, eftersom body byggs upp i förväg. Det undviker chunked-encoding om målsystemet inte accepterar det.
Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie
Multipart löser fortfarande inte integrationsproblemen: ni behöver timeouts, felklassificering och eventuellt retries. Viktigt är skillnaden mellan idempotent och nicht idempotent: uppladdningar är ofta inte idempotenta (dubletter möjliga). Retries bör därför endast ske om servern erbjuder idempotent semantik (z. B. Upload-ID, dedizierter Idempotency-Key Header) oder Sie serverseitig Deduplizierung haben.
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;
Fallgropar i praktiken
- Streamposition: Om FileStream inte är på position 0 laddas bara resten upp. I buildern tvingas därför
Seek(0). - Chunked vs. Content-Length: Vissa Gateways (eller äldre Server-Stacks) avvisar chunked. Det är ett vanligt legacy-fall i processnära mjukvarulösningar. Spool-to-File är då pragmatiskt.
- CRLF: Multipart förväntar sig CRLF (
#13#10), inte bara LF. Vissa servrar är toleranta, andra inte. - Content-Type per fil: Om ni skickar
application/octet-streamsom standard är det ofta ok. Om servern kontrollerar (t.ex. PDF) ställ in det korrekt. I Delphi kan ni lösa MIME-mappning via egen tabell eller OS-funktioner, men lita inte blint på filtillägg.
Debugging: reproducerbar Wire-Dump ohne TLS-Aufbruch
Vid HTTPS ser du inte body i proxyn om du inte får använda en MitM (t.ex. Fiddler-certifikat). Det är normalt i företagsmiljöer. Buildern hjälper eftersom du har hela body som en ström och (vid Spool-Datei) som en fil.
Beprövat arbetssätt:
- Skriv Spool-Body till en temporär fil.
- Logga
Content-Typeinklusive boundary ochContent-Length. - Skapa vid behov ett
curl-repro för Support/DevOps: Här behöver du inte återge body 1:1, men du kan spegla parametrarna och fil(er).
Viktigt: Logga aldrig produktiva tokens eller personuppgifter. I många affärssoftwareintegrationer är det precis den delen som är compliance-relevant.
Varianter: flera filer, valfria fält, servrar med ‚konstiga‘ förväntningar
Flera filer under samma fältnamn
Många API:er förväntar sig files[] eller flera gånger samma namn. Buildern stödjer detta direkt: Anropa AddFile flera gånger med samma FieldName. Om du använder files, files[] eller attachments är en ren serverkonvention.
Servern kräver exakt „application/json“ som ett extra part
Ett vanligt mönster: ett JSON-metadatablock plus fil. Då skickar du JSON:en som ett fältpart, men med Content-Type: application/json; charset=utf-8. Det är inte ett „form field“ i UI-betydelse, men det återges enkelt i Multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Servern accepterar bara filename, inte filename*
Då hjälper fallback via filename. Om servern dock avkodar icke-ASCII i filename felaktigt, är den robusta vägen ofta att ignorera filnamnet på serversidan och istället skicka ett extra fält originalName i JSON:en.
Klassificering för modernisering och drift
I etablerade Delphi-landskap sitter Multipart ofta i utkanten: ett gränssnitt till DMS, arkiv, ticketing, kundportal eller en intern REST-Server. Precis där uppstår tryck från nya säkerhetskrav (TLS, Gateways, Proxies) och från större filstorlekar.
Den presenterade metoden är särskilt värdefull när:
- Du behöver felsöka uppladdningar reproducerbart (drift/administration)
- Du vill/behöver undvika Chunked
- Filnamn/enkodningar faktiskt förekommer i praktiken (umlauter, mellanslag, parenteser)
- Retry/Idempotency ska vara konceptuellt väl löst
Den är mindre meningsfull när du endast skickar små filer till en tolerant server och inte behöver någon drifttransparens. Då räcker en enkel High-Level-lösning – tills den första ‚konstiga‘ filen från verksamheten dyker upp.
Slutsats: Stabil multipart-upload är ett streaming- och driftproblem
En ren Multipart/Form-Data Upload i Delphi är mindre en fråga om „vilken komponent“ än om kontroll: Boundary, CRLF, filnamn, Content-Type och framför allt en deterministisk Body-Stream. Den som bygger detta korrekt tidigt sparar tid senare i felsökningsloopar med API-Gateways och Reverse-Proxies.
Tillämpningsgräns för angreppssättet: Om ni måste ladda upp extremt stora filer (flera GB) utan spooling och utan Content-Length blir frågan om Streaming utan förhandsberäkning relevant – då måste målservern och infrastrukturen på ett tillförlitligt sätt stödja Chunked, och ni behöver ett annat debuggningskoncept. För många integrationer i digitala företagslösningar är den här visade Buildern dock precis den pragmatiska mitten mellan robusthet, spårbarhet och kontrollerbar resursförbrukning.
Om ni har en befintlig Delphi-integration där uppladdningar sporadiskt misslyckas eller bara „för vissa filer“ är det oftast en indikation på precis dessa randvillkor. För riktat stöd vid analys, modernisering eller driftavklaring når ni oss här:
I det tekniska sammanhanget spelar även Delphi Thttpclient och REST API-filuppladdning en viktig roll när integrationer, dataflöden och vidareutveckling måste samspela på ett ordnat sätt.