1 module hunt.http.client.MultipartBody;
2 
3 import hunt.http.HttpBody;
4 import hunt.http.HttpHeader;
5 import hunt.http.HttpField;
6 import hunt.http.HttpFields;
7 import hunt.http.HttpOutputStream;
8 
9 import hunt.collection.ArrayList;
10 import hunt.io.ByteBuffer;
11 import hunt.io.BufferUtils;
12 import hunt.collection.List;
13 import hunt.Exceptions;
14 import hunt.logging;
15 import hunt.util.StringBuilder;
16 
17 import hunt.util.MimeType;
18 
19 import std.array;
20 import std.conv;
21 import std.path;
22 import std.string;
23 import std.uuid;
24 
25 alias MediaType = string;
26 
27 
28 /** An <a href="http://www.ietf.org/rfc/rfc2387.txt">RFC 2387</a>-compliant request body. */
29 final class MultipartBody : HttpBody {
30     /**
31      * The "mixed" subtype of "multipart" is intended for use when the body parts are independent and
32      * need to be bundled in a particular order. Any "multipart" subtypes that an implementation does
33      * not recognize must be treated as being of subtype "mixed".
34      */
35     enum MediaType MIXED = MimeType.MULTIPART_MIXED_VALUE;
36 
37     /**
38      * The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the
39      * semantics are different. In particular, each of the body parts is an "alternative" version of
40      * the same information.
41      */
42     enum MediaType ALTERNATIVE = MimeType.MULTIPART_ALTERNATIVE_VALUE;
43 
44     /**
45      * This type is syntactically identical to "multipart/mixed", but the semantics are different. In
46      * particular, in a digest, the default {@code Content-Type} value for a body part is changed from
47      * "text/plain" to "message/rfc822".
48      */
49     enum MediaType DIGEST = MimeType.MULTIPART_DIGEST_VALUE;
50 
51     /**
52      * This type is syntactically identical to "multipart/mixed", but the semantics are different. In
53      * particular, in a parallel entity, the order of body parts is not significant.
54      */
55     enum MediaType PARALLEL = MimeType.MULTIPART_PARALLEL_VALUE;
56 
57     /**
58      * The media-type multipart/form-data follows the rules of all multipart MIME data streams as
59      * outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who
60      * fills out the form. Each field has a name. Within a given form, the names are unique.
61      */
62     enum MediaType FORM = MimeType.MULTIPART_FORM_VALUE;
63 
64     private enum byte[] COLONSPACE = [':', ' '];
65     private enum byte[] CRLF = ['\r', '\n'];
66     private enum byte[] DASHDASH = ['-', '-'];
67 
68     private string _boundary;
69     private MediaType _originalType;
70     private MediaType _contentType;
71     private List!Part _parts;
72     private long _contentLength = -1L;
73     private bool _isChunked = false;
74 
75     this(string boundary, MediaType type, List!Part parts) {
76         this._boundary = boundary;
77         this._originalType = type;
78         this._contentType = type ~ "; boundary=" ~ boundary;
79         this._parts = parts;
80     }
81 
82     bool isChunked() {
83         return _isChunked;
84     }
85 
86     void isChunked(bool flag) {
87         _isChunked = flag;
88     }
89 
90     MediaType type() {
91         return _originalType;
92     }
93 
94     string boundary() {
95         return _boundary;
96     }
97 
98     /** The number of parts in this multipart body. */
99     int size() {
100         return _parts.size();
101     }
102 
103     List!Part parts() {
104         return _parts;
105     }
106 
107     Part part(int index) {
108         return _parts.get(index);
109     }
110 
111     /** A combination of {@link #type()} and {@link #boundary()}. */
112     override MediaType contentType() {
113         return _contentType;
114     }
115 
116     override long contentLength() {
117         if(_isChunked) {
118             return -1;
119         } else {
120             if (_contentLength == -1)
121                 _contentLength = writeOrCountBytes!(true)(null);
122             return _contentLength;
123         }
124     }
125     
126     override void writeTo(HttpOutputStream sink) {
127         writeOrCountBytes!false(sink);
128     }
129 
130     /**
131      * Either writes this request to {@code sink} or measures its content length. We have one method
132      * do double-duty to make sure the counting and content are consistent, particularly when it comes
133      * to awkward operations like measuring the encoded length of header strings, or the
134      * length-in-digits of an encoded integer.
135      */
136     private long writeOrCountBytes(bool canCount=false)(HttpOutputStream sink) {
137         long byteCount = 0L;
138         Appender!(byte[]) buffer;
139         buffer.reserve(512);
140         
141         foreach (Part part; _parts) {
142             HttpFields headers = part._headers;
143 
144             buffer.put(DASHDASH);
145             buffer.put(cast(byte[])_boundary);
146             buffer.put(CRLF);
147 
148             foreach (HttpField field; headers) {
149                 buffer.put(cast(byte[])field.getName());
150                 buffer.put(COLONSPACE);
151                 buffer.put(cast(byte[])field.getValue());
152                 buffer.put(CRLF);
153             }
154 
155             HttpBody requestBody = part._body;
156             MediaType contentType = requestBody.contentType();
157             long length = requestBody.contentLength();
158 
159             if (contentType !is null) {
160                 buffer.put(cast(byte[])"Content-Type: ");
161                 buffer.put(cast(byte[])contentType);
162                 buffer.put(CRLF);
163             }
164 
165             if (length != -1) {
166                 buffer.put(cast(byte[])"Content-Length: ");
167                 buffer.put(cast(byte[])(length.to!string()));
168                 buffer.put(CRLF);
169             } else {
170                 string msg = "We can't measure the body's size without the sizes of its components. part body: %s";
171                 msg = format(msg, typeid(requestBody));
172                 version(HUNT_DEBUG) warning(msg);
173                 
174                 static if(canCount) {
175                     throw new Exception(msg);
176                 }
177             }
178 
179             buffer.put(CRLF);
180 
181             static if(canCount) {
182                 byteCount += cast(long)buffer.data().length + length;
183             } else {
184                 sink.write(buffer.data());
185                 requestBody.writeTo(sink);
186             }
187             
188             buffer = Appender!(byte[])(); // reset the buffer
189             buffer.reserve(512);
190             buffer.put(CRLF);
191         }
192 
193         buffer.put(DASHDASH);
194         buffer.put(cast(byte[])_boundary);
195         buffer.put(DASHDASH);
196         buffer.put(CRLF);
197         buffer.put(CRLF);
198         
199         static if(canCount) {
200             byteCount += cast(long)buffer.data().length;
201         } else {
202             sink.write(buffer.data());
203         }
204 
205         return byteCount;
206     }
207 
208     /**
209      * Appends a quoted-string to a StringBuilder.
210      *
211      * <p>RFC 2388 is rather vague about how one should escape special characters in form-data
212      * parameters, and as it turns out Firefox and Chrome actually do rather different things, and
213      * both say in their comments that they're not really sure what the right approach is. We go with
214      * Chrome's behavior (which also experimentally seems to match what IE does), but if you actually
215      * want to have a good chance of things working, please avoid double-quotes, newlines, percent
216      * signs, and the like in your field names.
217      */
218     static void appendQuotedString(StringBuilder target, string key) {
219         target.append('"');
220         for (int i = 0; i < cast(int)key.length; i++) {
221             char ch = key[i];
222             switch (ch) {
223                 case '\n':
224                     target.append("%0A");
225                     break;
226                 case '\r':
227                     target.append("%0D");
228                     break;
229                 case '"':
230                     target.append("%22");
231                     break;
232                 default:
233                     target.append(ch);
234                     break;
235             }
236         }
237         target.append('"');
238     }
239 
240     static final class Part {
241 
242         private HttpFields _headers;
243         private HttpBody _body;
244 
245         private this(HttpFields headers, HttpBody requestBody) {
246             this._headers = headers;
247             this._body = requestBody;
248         }
249 
250         HttpFields headers() {
251             return _headers;
252         }
253 
254         HttpBody requestBody() {
255             return _body;
256         }
257 
258         static Part create(HttpBody requestBody) {
259             return create(cast(HttpFields)null, requestBody);
260         }
261 
262         static Part create(HttpFields headers, HttpBody requestBody) {
263             if (requestBody is null) {
264                 throw new NullPointerException("body is null");
265             }
266             if (headers !is null && headers.getField("Content-Type") !is null) {
267                 throw new IllegalArgumentException("Unexpected header: Content-Type");
268             }
269             if (headers !is null && headers.getField("Content-Length") !is null) {
270                 throw new IllegalArgumentException("Unexpected header: Content-Length");
271             }
272             return new Part(headers, requestBody);
273         }
274 
275         static Part createFormData(string name, string value) {
276             return createFormData(name, cast(string)null, HttpBody.create(cast(string)null, value));
277         }
278 
279         static Part createFormData(string name, string value, string contentType) {
280             return createFormData(name, cast(string)null, HttpBody.create(contentType, value));
281         }
282 
283         static Part createFormData(string name, string filename, HttpBody requestBody) {
284             if (name is null) {
285                 throw new NullPointerException("name is null");
286             }
287             StringBuilder disposition = new StringBuilder("form-data; name=");
288             appendQuotedString(disposition, name);
289 
290             // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
291             // strip the path information
292             string bn = baseName(filename);
293             if (!bn.empty) {
294                 disposition.append("; filename=");
295                 appendQuotedString(disposition, bn);
296             }
297 
298             HttpFields headers = new HttpFields();
299             headers.add(HttpHeader.CONTENT_DISPOSITION, disposition.toString());
300 
301             return create(headers, requestBody);
302         }        
303     }
304 
305     static final class Builder {
306         private string _boundary;
307         private MediaType type = FORM;
308         private List!Part parts;
309         private bool _isChunked = false;
310 
311         this() {
312             this(randomUUID().toString());
313             parts = new ArrayList!Part();
314         }
315 
316         this(string boundary) {
317             this._boundary = boundary;
318             parts = new ArrayList!Part();
319         }
320 
321         /**
322          * Set the MIME type. Expected values for {@code type} are {@link #FORM} (the default), {@link
323          * #ALTERNATIVE}, {@link #DIGEST}, {@link #PARALLEL} and {@link #MIXED}.
324          */
325         Builder setType(MediaType type) {
326             if (type is null) {
327                 throw new NullPointerException("type is null");
328             }
329 
330             if(!type.startsWith("multipart/")) {
331                 throw new IllegalArgumentException("multipart != " ~ type);
332             }
333             this.type = type;
334             return this;
335         }
336 
337         /** Add a part to the body. */
338         Builder addPart(HttpBody requestBody) {
339             return addPart(Part.create(requestBody));
340         }
341 
342         /** Add a part to the body. */
343         Builder addPart(HttpFields headers, HttpBody requestBody) {
344             return addPart(Part.create(headers, requestBody));
345         }
346 
347         /** Add a form data part to the body. */
348         Builder addFormDataPart(string name, string value) {
349             return addPart(Part.createFormData(name, value));
350         }
351         
352         Builder addFormDataPart(string name, string value, string contentType) {
353             return addPart(Part.createFormData(name, value, contentType));
354         }
355 
356         /** Add a form data part to the body. */
357         Builder addFormDataPart(string name, string filename, HttpBody requestBody) {
358             return addPart(Part.createFormData(name, filename, requestBody));
359         }
360 
361         /** Add a part to the body. */
362         Builder addPart(Part part) {
363             if (part is null) throw new NullPointerException("part is null");
364             parts.add(part);
365             return this;
366         }
367 
368         Builder enableChunk() {
369             _isChunked = true;
370             return this;
371         }
372 
373         /** Assemble the specified parts into a request body. */
374         MultipartBody build() {
375             if (parts.isEmpty()) {
376                 throw new IllegalStateException("Multipart body must have at least one part.");
377             }
378             auto r = new MultipartBody(_boundary, type, parts);
379             r.isChunked = _isChunked;
380 
381             return r;
382         }
383     }
384 }