1 /**********************************************************************************
2 
3 HTTP client library, inspired by python-requests with goals:
4 
5  $(UL
6    $(LI small memory footprint)
7    $(LI performance)
8    $(LI simple, high level API)
9    $(LI native D implementation)
10  )
11 */
12 module requests;
13 
14 public import requests.http;
15 public import requests.ftp;
16 public import requests.streams;
17 public import requests.base;
18 public import requests.uri;
19 public import requests.request;
20 public import requests.pool;
21 public import requests.utils;
22 public import requests.rangeadapter;
23 
24 import std.datetime;
25 import std.conv;
26 import std.experimental.logger;
27 import requests.utils;
28 
29 ///
30 package unittest {
31     import std.algorithm;
32     import std.range;
33     import std.array;
34     import std.json;
35     import std.stdio;
36     import std.string;
37     import std.exception;
38 
39     string httpbinUrl = httpTestServer();
40     globalLogLevel(LogLevel.info);
41 
42     version(vibeD) {
43     }
44     else {
45         import httpbin;
46         auto server = httpbinApp();
47         server.start();
48         scope(exit) {
49             server.stop();
50         }
51     }
52 
53 
54     infof("testing Request");
55     Request rq;
56     Response rs;
57     // moved from http.d
58 
59     URI uri = URI(httpbinUrl);
60     rs = rq.get(httpbinUrl);
61     assert(rs.code==200);
62     assert(rs.responseBody.length > 0);
63     assert(rq.format("%m|%h|%p|%P|%q|%U") ==
64     "GET|%s|%d|%s||%s"
65     .format(uri.host, uri.port, uri.path, httpbinUrl));
66 
67     //
68     rs = rq.get(httpbinUrl);
69     assert(rs.code==200);
70     assert(rs.responseBody.length > 0);
71     rs = rq.get(httpbinUrl ~ "get", ["c":" d", "a":"b"]);
72     assert(rs.code == 200);
73     auto json = parseJSON(cast(string)rs.responseBody.data).object["args"].object;
74     assert(json["c"].str == " d");
75     assert(json["a"].str == "b");
76 
77     
78     rq = Request();
79     rq.keepAlive = false; // disable keepalive on idempotents requests
80     {
81         info("Check handling incomplete status line");
82         rs = rq.get(httpbinUrl ~ "incomplete");
83         if (httpbinUrl != "http://httpbin.org/") {
84             // 600 when test direct server respond, or 502 if test through squid
85             assert(rs.code==600 || rs.code == 502);
86         }
87     }
88     // handmade json
89     info("Check POST json");
90     rs = rq.post(httpbinUrl ~ "post?b=x", `{"a":"b ", "c":[1,2,3]}`, "application/json");
91     assert(rs.code==200);
92     json = parseJSON(cast(string)rs.responseBody.data).object["args"].object;
93     assert(json["b"].str == "x");
94     json = parseJSON(cast(string)rs.responseBody.data).object["json"].object;
95     assert(json["a"].str == "b ");
96     assert(json["c"].array.map!(a=>a.integer).array == [1,2,3]);
97     {
98         import std.file;
99         import std.path;
100         auto tmpd = tempDir();
101         auto tmpfname = tmpd ~ dirSeparator ~ "request_test.txt";
102         auto f = File(tmpfname, "wb");
103         f.rawWrite("abcdefgh\n12345678\n");
104         f.close();
105         // files
106         info("Check POST files");
107         PostFile[] files = [
108             {fileName: tmpfname, fieldName:"abc", contentType:"application/octet-stream"}, 
109             {fileName: tmpfname}
110         ];
111         rs = rq.post(httpbinUrl ~ "post", files);
112         assert(rs.code==200);
113         info("Check POST chunked from file.byChunk");
114         f = File(tmpfname, "rb");
115         rs = rq.post(httpbinUrl ~ "post", f.byChunk(3), "application/octet-stream");
116         if (httpbinUrl != "http://httpbin.org/" ) {
117             assert(rs.code==200);
118             auto data = fromJsonArrayToStr(parseJSON(cast(string)rs.responseBody).object["data"]);
119             assert(data=="abcdefgh\n12345678\n");
120         }
121         f.close();
122     }
123     // ranges
124     {
125         info("Check POST chunked from lineSplitter");
126         auto s = lineSplitter("one,\ntwo,\nthree.");
127         rs = rq.exec!"POST"(httpbinUrl ~ "post", s, "application/octet-stream");
128         if (httpbinUrl != "http://httpbin.org/" ) {
129             assert(rs.code==200);
130             auto data = fromJsonArrayToStr(parseJSON(cast(string)rs.responseBody).object["data"]);
131             assert(data=="one,two,three.");
132         }
133     }
134     {
135         info("Check POST chunked from array");
136         auto s = ["one,", "two,", "three."];
137         rs = rq.post(httpbinUrl ~ "post", s, "application/octet-stream");
138         if (httpbinUrl != "http://httpbin.org/" ) {
139             assert(rs.code==200);
140             auto data = fromJsonArrayToStr(parseJSON(cast(string)rs.responseBody).object["data"]);
141             assert(data=="one,two,three.");
142         }
143     }
144     {
145         info("Check POST chunked using std.range.chunks()");
146         auto s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
147         rs = rq.post(httpbinUrl ~ "post", s.representation.chunks(10), "application/octet-stream");
148         if (httpbinUrl != "http://httpbin.org/") {
149             assert(rs.code==200);
150             auto data = fromJsonArrayToStr(parseJSON(cast(string)rs.responseBody).object["data"]);
151             assert(data==s);
152         }
153     }
154     info("Check POST from QueryParams");
155     {
156         rs = rq.post(httpbinUrl ~ "post", queryParams("name[]", "first", "name[]", 2));
157         assert(rs.code==200);
158         auto data = parseJSON(cast(string)rs.responseBody).object["form"].object;
159         string[] a;
160         try {
161             a = to!(string[])(data["name[]"].str);
162         }
163         catch (JSONException e) {
164             a = data["name[]"].array.map!"a.str".array;
165         }
166         assert(equal(["first", "2"], a));
167     }
168     info("Check POST json");
169     {
170         rs = rq.post(httpbinUrl ~ "post?b=x", `{"a":"a b", "c":[1,2,3]}`, "application/json");
171         assert(rs.code==200);
172         auto j = parseJSON(cast(string)rs.responseBody).object["args"].object;
173         assert(j["b"].str == "x");
174         j = parseJSON(cast(string)rs.responseBody).object["json"].object;
175         assert(j["a"].str == "a b");
176         assert(j["c"].array.map!(a=>a.integer).array == [1,2,3]);
177     }
178     // associative array
179     info("Check POST from AA");
180     rs = rq.post(httpbinUrl ~ "post", ["a":"b ", "c":"d"]);
181     assert(rs.code==200);
182     auto form = parseJSON(cast(string)rs.responseBody.data).object["form"].object;
183     assert(form["a"].str == "b ");
184     assert(form["c"].str == "d");
185     info("Check HEAD");
186     rs = rq.exec!"HEAD"(httpbinUrl);
187     assert(rs.code==200);
188     rs = rq.execute("HEAD", httpbinUrl);
189     assert(rs.code==200);
190     info("Check DELETE");
191     rs = rq.exec!"DELETE"(httpbinUrl ~ "delete");
192     assert(rs.code==200);
193     rs = rq.execute("DELETE", httpbinUrl ~ "delete");
194     assert(rs.code==200);
195     info("Check PUT");
196     rs = rq.exec!"PUT"(httpbinUrl ~ "put",  `{"a":"b", "c":[1,2,3]}`, "application/json");
197     assert(rs.code==200);
198     rs = rq.execute("PUT", httpbinUrl ~ "put",  `{"a":"b", "c":[1,2,3]}`, "application/json");
199     assert(rs.code==200);
200     info("Check PATCH");
201     rs = rq.exec!"PATCH"(httpbinUrl ~ "patch", "привiт, свiт!", "application/octet-stream");
202     assert(rs.code==200);
203     rs = rq.execute("PATCH", httpbinUrl ~ "patch", "привiт, свiт!", "application/octet-stream");
204     assert(rs.code==200);
205 
206     info("Check compressed content");
207     rq = Request();
208     rq.keepAlive = true;
209     rq.addHeaders(["X-Header": "test"]);
210     rs = rq.get(httpbinUrl ~ "gzip");
211     assert(rs.code==200);
212     info("gzip - ok");
213     rs = rq.get(httpbinUrl ~ "deflate");
214     assert(rs.code==200);
215     info("deflate - ok");
216     
217     info("Check cookie");
218     rq = Request();
219     rs = rq.get(httpbinUrl ~ "cookies/set?A=abcd&b=cdef");
220     assert(rs.code == 200);
221     json = parseJSON(cast(string)rs.responseBody.data).object["cookies"].object;
222     assert(json["A"].str == "abcd");
223     assert(json["b"].str == "cdef");
224     foreach(c; rq.cookie._array) {
225         final switch(c.attr) {
226             case "A":
227                 assert(c.value == "abcd");
228                 break;
229             case "b":
230                 assert(c.value == "cdef");
231                 break;
232         }
233     }
234 
235     info("Check redirects");
236     rq = Request();
237     rq.keepAlive = true;
238     rs = rq.get(httpbinUrl ~ "relative-redirect/2");
239     assert((cast(HTTPResponse)rs).history.length == 2);
240     assert((cast(HTTPResponse)rs).code==200);
241 
242     rs = rq.get(httpbinUrl ~ "absolute-redirect/2");
243     assert((cast(HTTPResponse)rs).history.length == 2);
244     assert((cast(HTTPResponse)rs).code==200);
245     //    rq = Request();
246     info("Check maxredirects");
247     rq.maxRedirects = 2;
248     rq.keepAlive = false;
249     assertThrown!MaxRedirectsException(rq.get(httpbinUrl ~ "absolute-redirect/3"));
250 
251     rq.maxRedirects = 0;
252     rs = rq.get(httpbinUrl ~ "absolute-redirect/1");
253     assert(rs.code==302);
254     rq.maxRedirects = 10;
255 
256     info("Check chunked content");
257     rq = Request();
258     rq.keepAlive = true;
259     rq.bufferSize = 16*1024;
260     rs = rq.get(httpbinUrl ~ "range/1024");
261     assert(rs.code==200);
262     assert(rs.responseBody.length==1024);
263     
264     info("Check basic auth");
265     rq = Request();
266     rq.authenticator = new BasicAuthentication("user", "passwd");
267     rs = rq.get(httpbinUrl ~ "basic-auth/user/passwd");
268     assert(rs.code==200);
269     
270     info("Check limits");
271     rq = Request();
272     rq.maxContentLength = 1;
273     assertThrown!RequestException(rq.get(httpbinUrl));
274     rq = Request();
275     rq.maxHeadersLength = 1;
276     assertThrown!RequestException(rq.get(httpbinUrl));
277 
278     info("Test getContent");
279     auto r = getContent(httpbinUrl ~ "stream/20");
280     assert(r.splitter('\n').filter!("a.length>0").count == 20);
281     r = getContent(httpbinUrl ~ "get", ["a":"b", "c":"d"]);
282     string name = "user", sex = "male";
283     immutable age = 42;
284     r = getContent(httpbinUrl ~ "get", "name", name, "age", age, "sex", sex);
285 
286     info("Test receiveAsRange with GET");
287     rq = Request();
288     rq.useStreaming = true;
289     rq.bufferSize = 16;
290     rs = rq.get(httpbinUrl ~ "stream/20");
291     auto stream = rs.receiveAsRange();
292     ubyte[] streamedContent;
293     while( !stream.empty() ) {
294         streamedContent ~= stream.front;
295         stream.popFront();
296     }
297     rq = Request();
298     rs = rq.get(httpbinUrl ~ "stream/20");
299     assert(streamedContent.length == rs.responseBody.data.length,
300             "streamedContent.length(%d) == rs.responseBody.data.length(%d)".
301             format(streamedContent.length, rs.responseBody.data.length));
302     info("Test postContent");
303     r = postContent(httpbinUrl ~ "post", `{"a":"b", "c":1}`, "application/json");
304     assert(parseJSON(cast(string)r).object["json"].object["c"].integer == 1);
305 
306     /// Posting to forms (for small data)
307     ///
308     /// posting query parameters using "application/x-www-form-urlencoded"
309     info("Test postContent using query params");
310     r = postContent(httpbinUrl ~ "post", queryParams("first", "a", "second", 2));
311     assert(parseJSON(cast(string)r).object["form"].object["first"].str == "a");
312     
313     /// posting using multipart/form-data (large data and files). See docs fot HTTPRequest
314     info("Test postContent form");
315     MultipartForm mpform;
316     mpform.add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
317     r = postContent(httpbinUrl ~ "post", mpform);
318     assert(parseJSON(cast(string)r).object["form"].object["greeting"].str == "hello");
319     
320     /// you can do this using Request struct to access response details
321     info("Test postContent form via Request()");
322     rq = Request();
323     mpform = MultipartForm().add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
324     rs = rq.post(httpbinUrl ~ "post", mpform);
325     assert(rs.code == 200);
326     assert(parseJSON(cast(string)(rs.responseBody().data)).object["form"].object["greeting"].str == "hello");
327 
328     info("Test receiveAsRange with POST");
329     streamedContent.length = 0;
330     rq = Request();
331     rq.useStreaming = true;
332     rq.bufferSize = 16;
333     string s = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
334     rs = rq.post(httpbinUrl ~ "post", s.representation, "application/octet-stream");
335     stream = rs.receiveAsRange();
336     while( !stream.empty() ) {
337         streamedContent ~= stream.front;
338         stream.popFront();
339     }
340     rq = Request();
341     rs = rq.post(httpbinUrl ~ "post", s.representation, "application/octet-stream");
342     assert(streamedContent == rs.responseBody.data);
343 
344     streamedContent.length = 0;
345     rq = Request();
346     rq.useStreaming = true;
347     rq.bufferSize = 16;
348     mpform = MultipartForm().add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
349     rs = rq.post(httpbinUrl ~ "post", mpform);
350     stream = rs.receiveAsRange();
351     while( !stream.empty() ) {
352         streamedContent ~= stream.front;
353         stream.popFront();
354     }
355     info("Check POST files using multiPartForm");
356     {
357         /// This is example on usage files with MultipartForm data.
358         /// For this example we have to create files which will be sent.
359         import std.file;
360         import std.path;
361         /// preapare files
362         auto tmpd = tempDir();
363         auto tmpfname1 = tmpd ~ dirSeparator ~ "request_test1.txt";
364         auto f = File(tmpfname1, "wb");
365         f.rawWrite("file1 content\n");
366         f.close();
367         auto tmpfname2 = tmpd ~ dirSeparator ~ "request_test2.txt";
368         f = File(tmpfname2, "wb");
369         f.rawWrite("file2 content\n");
370         f.close();
371         ///
372         /// Ok, files ready.
373         /// Now we will prepare Form data
374         ///
375         File f1 = File(tmpfname1, "rb");
376         File f2 = File(tmpfname2, "rb");
377         scope(exit) {
378             f1.close();
379             f2.close();
380         }
381         ///
382         /// for each part we have to set field name, source (ubyte array or opened file) and optional filename and content-type
383         ///
384         MultipartForm mForm = MultipartForm().
385         add(formData("Field1", cast(ubyte[])"form field from memory")).
386         add(formData("Field2", cast(ubyte[])"file field from memory", ["filename":"data2"])).
387         add(formData("File1", f1, ["filename":"file1", "Content-Type": "application/octet-stream"])).
388         add(formData("File2", f2, ["filename":"file2", "Content-Type": "application/octet-stream"]));
389         /// everything ready, send request
390         rs = rq.post(httpbinUrl ~ "post", mForm);
391     }
392 
393     rq = Request();
394     mpform = MultipartForm().add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
395     rs = rq.post(httpbinUrl ~ "post", mpform);
396     assert(parseJSON(cast(string)(rs.responseBody().data)).object["form"].object["greeting"].str ==
397            parseJSON(cast(string)streamedContent).object["form"].object["greeting"].str);
398 
399 
400     info("Test POST'ing from Rank(2) range with user-provided Content-Length");
401     rq.addHeaders(["content-length": to!string(s.length)]);
402     rs = rq.post(httpbinUrl ~ "post", s.representation.chunks(10), "application/octet-stream");
403     auto flat_content = parseJSON(cast(string)rs.responseBody().data).object["data"];
404     if ( flat_content.type == JSONType..string ) {
405         // httpbin.org returns string in "data"
406         assert(s == flat_content.str);
407     } else {
408         // internal httpbin server return array ob bytes
409         assert(s.representation == flat_content.array.map!(i => i.integer).array);
410     }
411 
412     /// Putting to forms (for small data)
413     ///
414     /// putting query parameters using "application/x-www-form-urlencoded"
415     info("Test putContent using query params");
416     r = putContent(httpbinUrl ~ "put", queryParams("first", "a", "second", 2));
417     assert(parseJSON(cast(string)r).object["form"].object["first"].str == "a");
418     
419     /// putting using multipart/form-data (large data and files). See docs fot HTTPRequest
420     info("Test putContent form");
421     mpform = MultipartForm();
422     mpform.add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
423     r = putContent(httpbinUrl ~ "put", mpform);
424     assert(parseJSON(cast(string)r).object["form"].object["greeting"].str == "hello");
425 
426     /// Patching to forms (for small data)
427     ///
428     /// patching query parameters using "application/x-www-form-urlencoded"
429     info("Test patchContent using query params");
430     r = patchContent(httpbinUrl ~ "patch", queryParams("first", "a", "second", 2));
431     assert(parseJSON(cast(string)r).object["form"].object["first"].str == "a");
432     
433     /// patching using multipart/form-data (large data and files). See docs fot HTTPRequest
434     info("Test patchContent form");
435     mpform = MultipartForm();
436     mpform.add(formData(/* field name */ "greeting", /* content */ cast(ubyte[])"hello"));
437     r = patchContent(httpbinUrl ~ "patch", mpform);
438     assert(parseJSON(cast(string)r).object["form"].object["greeting"].str == "hello");
439 
440     info("Check exception handling, error messages and timeouts are OK");
441     rq.clearHeaders();
442     rq.timeout = 1.seconds;
443     assertThrown!TimeoutException(rq.get(httpbinUrl ~ "delay/3"));
444 
445     info("Test get in parallel");
446     {
447         import std.stdio;
448         import std.parallelism;
449         import std.algorithm;
450         import std.string;
451         import core.atomic;
452         
453         immutable auto urls = [
454             "stream/10",
455             "stream/20",
456             "stream/30",
457             "stream/40",
458             "stream/50",
459             "stream/60",
460             "stream/70",
461         ].map!(a => httpbinUrl ~ a).array.idup;
462         
463         defaultPoolThreads(4);
464         
465         shared short lines;
466         
467         foreach(url; parallel(urls)) {
468             atomicOp!"+="(lines, getContent(url).splitter("\n").count);
469         }
470         assert(lines == 287);
471         
472     }
473 }
474 /**
475  * Call GET, and return response content.
476  *
477  * This is the simplest case, when all you need is the response body and have no parameters.
478  * Returns:
479  * Buffer!ubyte which you can use as ForwardRange or DirectAccessRange, or extract data with .data() method.
480  */
481 public auto ref getContent(string url) {
482     auto rq = Request();
483     auto rs = rq.get(url);
484     return rs.responseBody;
485 }
486 /**
487   Call GET and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
488  */
489 public auto getContentByLine(string url)
490 {
491     auto rq = Request();
492     rq.useStreaming = true;
493     auto rs = rq.get(url);
494     _LineReader lr = _LineReader(rq, rs);
495     return lr;
496 }
497 /**
498  * Call GET, with parameters, and return response content.
499  *
500  * args = string[string] fo query parameters.
501  * Returns:
502  * Buffer!ubyte which you can use as ForwardRange or DirectAccessRange, or extract data with .data() method.
503  */
504 public auto ref getContent(string url, string[string] args) {
505     auto rq = Request();
506     auto rs = rq.get(url, args);
507     return rs.responseBody;
508 }
509 /**
510   Call GET and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
511  */
512 public auto getContentByLine(string url, string[string] args)
513 {
514     auto rq = Request();
515     rq.useStreaming = true;
516     auto rs = rq.get(url, args);
517     _LineReader lr = _LineReader(rq, rs);
518     return lr;
519 }
520 /**
521  * Call GET, with parameters, and return response content.
522  * 
523  * args = QueryParam[] of parameters.
524  * Returns:
525  * Buffer!ubyte which you can use as ForwardRange or DirectAccessRange, or extract data with .data() method.
526  */
527 public auto ref getContent(string url, QueryParam[] args) {
528     auto rq = Request();
529     auto rs = rq.get(url, args);
530     return rs.responseBody;
531 }
532 /**
533   Call GET and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
534  */
535 public auto getContentByLine(string url, QueryParam[] args)
536 {
537     auto rq = Request();
538     rq.useStreaming = true;
539     auto rs = rq.get(url, args);
540     _LineReader lr = _LineReader(rq, rs);
541     return lr;
542 }
543 /**
544  * Call GET, and return response content.
545  * args = variadic args to supply parameter names and values.
546  * Returns:
547  * Buffer!ubyte which you can use as ForwardRange or DirectAccessRange, or extract data with .data() method.
548  */
549 public auto ref getContent(A...)(string url, A args) if (args.length > 1 && args.length % 2 == 0 ) {
550     return Request().
551             get(url, queryParams(args)).
552             responseBody;
553 }
554 /**
555   Call GET and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
556  */
557 public auto getContentByLine(A...)(string url, A args) if (args.length > 1 && args.length % 2 == 0 )
558 {
559     auto rq = Request();
560     rq.useStreaming = true;
561     auto rs = rq.get(url, queryParams(args));
562     _LineReader lr = _LineReader(rq, rs);
563     return lr;
564 }
565 ///
566 /// Call post and return response content.
567 ///
568 public auto postContent(A...)(string url, A args) {
569     auto rq = Request();
570     auto rs = rq.post(url, args);
571     return rs.responseBody;
572 }
573 /**
574   Call POST and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
575  */
576 public auto postContentByLine(A...)(string url, A args)
577 {
578     auto rq = Request();
579     rq.useStreaming = true;
580     auto rs = rq.post(url, args);
581     _LineReader lr = _LineReader(rq, rs);
582     return lr;
583 }
584 
585 ///
586 /// Call put and return response content.
587 ///
588 public auto putContent(A...)(string url, A args) {
589     auto rq = Request();
590     auto rs = rq.put(url, args);
591     return rs.responseBody;
592 }
593 /**
594   Call PUT and lazily convert server response into InputRange of ubyte[], splitted by '\n'.
595  */
596 public auto putContentByLine(A...)(string url, A args)
597 {
598     auto rq = Request();
599     rq.useStreaming = true;
600     auto rs = rq.put(url, args);
601     _LineReader lr = _LineReader(rq, rs);
602     return lr;
603 }
604 
605 ///
606 /// Call patch and return response content.
607 ///
608 public auto patchContent(A...)(string url, A args) {
609     auto rq = Request();
610     auto rs = rq.patch(url, args);
611     return rs.responseBody;
612 }
613 
614 unittest
615 {
616     import std.algorithm;
617     info("Test ByLine interfaces");
618     auto r = getContentByLine("https://httpbin.org/stream/50");
619     assert(r.count == 50);
620     r = getContentByLine("https://httpbin.org/stream/0");
621     assert(r.count == 0);
622     r = getContentByLine("https://httpbin.org/anything", ["1":"1"]);
623     assert(r.map!"cast(string)a".filter!(a => a.canFind("data")).count == 1);
624     r = getContentByLine("https://httpbin.org/anything", queryParams("1","1"));
625     assert(r.map!"cast(string)a".filter!(a => a.canFind("data")).count == 1);
626     r = postContentByLine("https://httpbin.org/anything", "123");
627     assert(r.map!"cast(string)a".filter!(a => a.canFind("data")).count == 1);
628     r = putContentByLine("https://httpbin.org/anything", "123");
629     assert(r.map!"cast(string)a".filter!(a => a.canFind("data")).count == 1);
630 }