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 }