1 /**
2  * Copyright 2017
3  * MIT License
4  * Phabricator Conduit API
5  */
6 module phabricator.api;
7 import core.time;
8 import phabricator.common;
9 import std.conv: to;
10 import std.json;
11 import std.net.curl;
12 import std..string: format, join, startsWith, toUpper;
13 import std.typecons;
14 import std.uri;
15 
16 // error code key
17 private enum ErrorCode = "error_code";
18 
19 // Posting code prefix for mixin
20 private enum PostPrefix = "val = cast(string)post(endpoint, ";
21 
22 // Posting code suffix for mixin
23 private enum PostSuffix = ", client);";
24 
25 // Encoded data from request
26 private enum PostEncoded = PostPrefix ~ "encoded" ~ PostSuffix;
27 
28 // Mapped values from request data
29 private enum PostMapped = PostPrefix ~ "mapped" ~ PostSuffix;
30 
31 /**
32  * Generalized exceptions
33  */
34 public class PhabricatorAPIException : Exception
35 {
36     @property public string content;
37     this(string msg, string content = null)
38     {
39         super(msg);
40         this.content = content;
41     }
42 }
43 
44 ///
45 version(PhabUnitTest)
46 {
47     unittest
48     {
49         auto error = new PhabricatorAPIException("test");
50         assert(error.msg == "test");
51         assert(error.content == null);
52         error = new PhabricatorAPIException("test", "blah");
53         assert(error.msg == "test");
54         assert(error.content == "blah");
55     }
56 }
57 
58 /**
59  * Remarkup API
60  */
61 public class RemarkupAPI : PhabricatorAPI
62 {
63     /**
64      * Process text
65      */
66     public JSONValue process(string context, string[] text)
67     {
68         auto req = DataRequest();
69         req.data["context"] = context;
70         int idx = 0;
71         foreach (item; text)
72         {
73             req.data["contents[" ~ to!string(idx) ~ "]"] = item;
74             idx++;
75         }
76 
77         return this.request(HTTP.Method.post,
78                             Category.remarkup,
79                             "process",
80                             &req);
81     }
82 }
83 
84 /**
85  * Wiki/phriction API
86  */
87 public class PhrictionAPI : PhabricatorAPI
88 {
89     /**
90      * Search by a phid to get content of a wiki page
91      */
92     public JSONValue searchByPHID(string phid, bool withContent = true)
93     {
94         auto req = this.activePHIDWithContent(phid, withContent);
95         return this.request(HTTP.Method.post,
96                             Category.phriction,
97                             "document.search",
98                             &req);
99     }
100 
101     /**
102      * Get page info
103      */
104     deprecated("phriction.info is frozen")
105     public JSONValue info(string slug)
106     {
107         auto req = DataRequest();
108         req.data["slug"] = slug;
109         return this.request(HTTP.Method.post,
110                             Category.phriction,
111                             "info",
112                             &req);
113     }
114 
115     /**
116      * Edit a page
117      */
118     public JSONValue edit(string slug, string title, string content)
119     {
120         auto req = DataRequest();
121         req.data["slug"] = slug;
122         req.data["title"] = title;
123         req.data["content"] = content;
124         return this.request(HTTP.Method.post,
125                             Category.phriction,
126                             "edit",
127                             &req);
128     }
129 }
130 
131 /**
132  * Dashboard api
133  */
134 public class DashboardAPI : PhabricatorAPI
135 {
136     /**
137      * Edit panel text
138      */
139     public JSONValue editText(string identifier, string text)
140     {
141         auto req = this.buildTrans(identifier,
142                                    [tuple("custom.text", text, false)]);
143         return this.request(HTTP.Method.post,
144                             Category.dashboard,
145                             "panel.edit",
146                             &req);
147     }
148 }
149 
150 /**
151  * Project API
152  */
153 public class ProjectAPI : PhabricatorAPI
154 {
155     /**
156      * Add a member to a project
157      */
158     public JSONValue addMember(string projectPHID, string userPHID)
159     {
160         auto req = this.buildTrans(projectPHID,
161                                    [tuple("members.add", userPHID, true)]);
162         return this.request(HTTP.Method.post,
163                             Category.project,
164                             "edit",
165                             &req);
166     }
167 
168     /**
169      * Active projects
170      */
171     public JSONValue active()
172     {
173         return this.activeRequest();
174     }
175 
176     /**
177      * Active projects with membership
178      */
179     public JSONValue membersActive()
180     {
181         return this.activeRequest(["members"]);
182     }
183 
184     /**
185      * Get active projects with attachments
186      */
187     private JSONValue activeRequest(string[] attachments = null)
188     {
189         // NOTE: needs to eventually support paging
190         auto req = this.fromKey("active");
191         if (attachments !is null)
192         {
193             foreach (attachment; attachments)
194             {
195                 req.data[format("attachments[%s]", attachment)] = "1";
196             }
197         }
198 
199         return this.request(HTTP.Method.post,
200                             Category.project,
201                             "search",
202                             &req);
203     }
204 }
205 
206 /**
207  * File API
208  */
209 public class FileAPI : PhabricatorAPI
210 {
211     /**
212      * Download a file
213      */
214     public JSONValue download(string phid)
215     {
216         auto req = DataRequest();
217         req.data["phid"] = phid;
218         return this.request(HTTP.Method.post,
219                             Category.file,
220                             "download",
221                             &req);
222     }
223 }
224 
225 /**
226  * Maniphest API for tasks
227  */
228 public class ManiphestAPI : PhabricatorAPI
229 {
230     // All tasks
231     private enum AllQuery = "all";
232 
233     // Open tasks
234     private enum OpenQuery = "open";
235 
236     /**
237      * Compile a set of task results into a single output
238      */
239     private static JSONValue tasks(JSONValue[] results)
240     {
241         JSONValue stitched = JSONValue();
242         stitched[ResultKey] = JSONValue();
243         stitched[ResultKey][DataKey] = JSONValue();
244         stitched[ResultKey][DataKey].array = [];
245         foreach (obj; results)
246         {
247             foreach (key; obj.object.keys)
248             {
249                 if (key == ResultKey)
250                 {
251                     auto sub = obj.object[key];
252                     foreach (subkey; sub.object.keys)
253                     {
254                         if (subkey == DataKey)
255                         {
256                             auto data = sub.object[subkey];
257                             foreach (o; data.array)
258                             {
259                                 stitched[ResultKey][DataKey].array ~= o;
260                             }
261                         }
262                     }
263                 }
264             }
265         }
266 
267         return stitched;
268     }
269 
270     /**
271      * Comment on a task
272      */
273     public JSONValue comment(string phid, string text)
274     {
275         return this.edit(phid, [tuple("comment", text, false)]);
276     }
277 
278     /**
279      * Edit a task
280      */
281     private JSONValue edit(string phid, Tuple!(string, string, bool)[] trans)
282     {
283         auto req = this.buildTrans(phid, trans);
284         return this.request(HTTP.Method.post,
285                             Category.maniphest,
286                             "edit",
287                             &req);
288     }
289 
290     /**
291      * Open tasks
292      */
293     public JSONValue open()
294     {
295         return this.byQueryKey(OpenQuery);
296     }
297 
298     /**
299      * Open by project
300      */
301     public JSONValue openProject(string projectPHID)
302     {
303         return this.openConstrained(projectPHID);
304     }
305 
306     /**
307      * Open and subscribed projects
308      */
309     public JSONValue openSubscribedProject(string projectPHID, string userPHID)
310     {
311         return this.openConstrained(projectPHID, userPHID);
312     }
313 
314     /**
315      * Add a project to a task
316      */
317     public JSONValue addProject(string phid, string projectPHID)
318     {
319         return this.edit(phid, [tuple("projects.add", projectPHID, true)]);
320     }
321 
322     /**
323      * Invalidate a task
324      */
325     public JSONValue invalid(string phid)
326     {
327         return this.edit(phid, [tuple("status", "invalid", false)]);
328     }
329 
330     /**
331      * Open query but with constraints
332      */
333     private JSONValue openConstrained(string projectPHIDs,
334                                       string ccPHIDs = null)
335     {
336         string[string] constraints;
337         if (projectPHIDs !is null)
338         {
339             constraints["projects"] = projectPHIDs;
340         }
341 
342         if (ccPHIDs !is null)
343         {
344             constraints["subscribers"] = ccPHIDs;
345         }
346 
347         auto req = this.getQuery(OpenQuery, constraints);
348         return this.search(req);
349     }
350 
351     /**
352      * All tasks
353      */
354     public JSONValue all()
355     {
356         return this.byQueryKey(AllQuery);
357     }
358 
359     /**
360      * Get all, by identifier
361      */
362     public JSONValue byIds(int[] identifiers)
363     {
364         auto req = this.getQuery(AllQuery);
365         if (identifiers !is null && identifiers.length > 0)
366         {
367             DataRequest.KeyValue[] function(string[]) iterate =
368                    function DataRequest.KeyValue[](string[] state)
369             {
370                 int idx = 0;
371                 DataRequest.KeyValue[] objs;
372                 foreach (id; state)
373                 {
374                     auto obj = DataRequest.KeyValue();
375                     obj.key = "constraints[ids][" ~ to!string(idx) ~ "]";
376                     obj.value = id;
377                     objs ~= obj;
378                     idx++;
379                 }
380 
381                 return objs;
382             };
383 
384             req.urlFunction = iterate;
385             foreach (id; identifiers)
386             {
387                 req.raw ~= to!string(id);
388             }
389         }
390 
391         req.urlEncode = true;
392         return this.search(req);
393     }
394 
395     /**
396      * Get a query request with key and constraints (optional)
397      */
398     private DataRequest getQuery(string key, string[string] constraints = null)
399     {
400         auto req = this.fromKey(key);
401         if (constraints !is null)
402         {
403             foreach (val; constraints.keys)
404             {
405                 req.data["constraints[" ~ val ~ "][0]"] = constraints[val];
406             }
407         }
408 
409         return req;
410     }
411 
412     /**
413      * Get by query key
414      */
415     public JSONValue byQueryKey(string key)
416     {
417         auto req = this.getQuery(key);
418         return this.search(req);
419     }
420 
421     /**
422      * Search requests
423      */
424     private JSONValue search(DataRequest req)
425     {
426         return this.getData("search", &req);
427     }
428 
429     /**
430      * Get data
431      */
432     private JSONValue getData(string call, DataRequest* req)
433     {
434         return this.paged(HTTP.Method.post,
435                           Category.maniphest,
436                           call,
437                           req,
438                           &tasks);
439     }
440 }
441 
442 /**
443  * Diffusion api
444  */
445 public class DiffusionAPI : PhabricatorAPI
446 {
447     /**
448      * Get file content by path, callsign, branch
449      */
450     public JSONValue fileContentByPathBranch(string path,
451                                              string callsign,
452                                              string branch)
453     {
454         string useCall = callsign;
455         if (callsign.startsWith("r"))
456         {
457             useCall = callsign[1..callsign.length];
458         }
459 
460         useCall = "r" ~ useCall.toUpper();
461         auto req = DataRequest();
462         req.data["path"] = path;
463         req.data["callsign"] = useCall;
464         req.data["branch"] = branch;
465         return this.request(HTTP.Method.post,
466                             Category.diffusion,
467                             "filecontentquery",
468                             &req);
469     }
470 }
471 
472 ///
473 version(PhabUnitTest)
474 {
475     /**
476      * Testing API
477      */
478     public class TestAPI : PhabricatorAPI
479     {
480         /**
481          * Cause an error
482          */
483         public JSONValue error()
484         {
485             return this.request(HTTP.Method.post,
486                                 Category.dashboard,
487                                 "error",
488                                 null);
489         }
490 
491         /**
492          * Get test data
493          */
494         public JSONValue get()
495         {
496             return this.request(HTTP.Method.post,
497                                 Category.dashboard,
498                                 "test",
499                                 null);
500         }
501 
502         /**
503          * Transaction testing
504          */
505         public DataRequest trans()
506         {
507             return this.buildTrans("test",
508                                    [tuple("abc", "xyz", true),
509                                     tuple("123", "456", false)]);
510         }
511 
512         unittest
513         {
514             auto api = new TestAPI();
515             auto trans = api.trans();
516             assert(trans.data.keys.length == 5);
517             assert(trans.data["objectIdentifier"] == "test");
518             assert(trans.data["transactions[0][type]"] == "abc");
519             assert(trans.data["transactions[1][type]"] == "123");
520             assert(trans.data["transactions[0][value][]"] == "xyz");
521             assert(trans.data["transactions[1][value]"] == "456");
522         }
523     }
524 
525     unittest
526     {
527         auto api = new TestAPI();
528         api.url = "url";
529         api.token = "token";
530         auto resp = api.get();
531         assert(resp.toJSON() == "{}");
532         try
533         {
534             api.error();
535             assert(false);
536         }
537         catch (PhabricatorAPIException e)
538         {
539             assert(e.msg == "Response error");
540         }
541     }
542 }
543 
544 // Provides stitching of result sets together
545 alias JSONValue function(JSONValue[]) StitchFunction;
546 
547 /**
548  * API categories
549  */
550 public enum Category : string
551 {
552     // categories of the API methods
553     phriction = "phriction", dashboard = "dashboard",
554         diffusion = "diffusion", file = "file",
555         maniphest = "maniphest", user = "user",
556         project = "project", remarkup = "remarkup",
557         paste = "paste", feed = "feed"
558 }
559 
560 /**
561  * Feed API
562  */
563 public class FeedAPI : PhabricatorAPI
564 {
565     /**
566      * Get a specified feed
567      */
568     public JSONValue getFeed(string phid, int limit=100)
569     {
570         auto req = DataRequest();
571         req.data["filterPHIDs[0]"] = phid;
572         req.data["limit"] = to!string(limit);
573         return this.request(HTTP.Method.post,
574                             Category.feed,
575                             "query",
576                             &req);
577     }
578 }
579 
580 /**
581  * User API
582  */
583 public class UserAPI : PhabricatorAPI
584 {
585     /**
586      * Get user information
587      */
588     deprecated("user.whoami is being deprecated upstream")
589         public JSONValue whoami()
590     {
591         return this.request(HTTP.Method.post,
592                             Category.user,
593                             "whoami",
594                             null);
595     }
596 
597     /**
598      * Get active users
599      */
600     public JSONValue activeUsers()
601     {
602         auto req = this.fromKey("active");
603         req.data["constraints[isBot]"] = "0";
604         return this.search(&req);
605     }
606 
607     /**
608      * Perform a search operation
609      */
610     private JSONValue search(DataRequest* req)
611     {
612         return this.request(HTTP.Method.post,
613                             Category.user,
614                             "search",
615                             req);
616     }
617 }
618 
619 /**
620  * Paste API
621  */
622 public class PasteAPI : PhabricatorAPI
623 {
624     /**
625      * Edit paste text
626      */
627     public JSONValue editText(string phid, string text)
628     {
629         auto req = this.buildTrans(phid,
630                                    [tuple("text", text, false)]);
631         return this.request(HTTP.Method.post,
632                             Category.paste,
633                             "edit",
634                             &req);
635     }
636 
637     /**
638      * Get an active paste by phid
639      */
640     public JSONValue activeByPHID(string phid, bool withContent = true)
641     {
642         auto req = this.activePHIDWithContent(phid, withContent);
643         return this.request(HTTP.Method.post,
644                             Category.paste,
645                             "search",
646                             &req);
647     }
648 }
649 
650 /**
651  * Phabricator API
652  */
653 public abstract class PhabricatorAPI
654 {
655     // conduit token
656     @property public string token;
657 
658     // url/host to use
659     @property public string url;
660 
661     // user PHID
662     @property public string userPHID;
663 
664     // client timeout
665     @property public int timeout;
666 
667     // init the instance
668     this ()
669     {
670         this.timeout = 30;
671     }
672 
673     /**
674      * Build request from query key
675      */
676     private DataRequest fromKey(string key)
677     {
678         auto req = DataRequest();
679         req.data["queryKey"] = key;
680         return req;
681     }
682 
683     /**
684      * Requests for active objects constrained to a phid WITH content
685      */
686     private DataRequest activePHIDWithContent(string phid, bool withContent)
687     {
688         auto req = this.fromKey("active");
689         req.data["constraints[phids][0]"] = phid;
690         if (withContent)
691         {
692             req.data["attachments[content]"] = "1";
693         }
694         return req;
695     }
696 
697     /**
698      * Build a transaction set
699      */
700     private DataRequest buildTrans(string id,
701                                    Tuple!(string, string, bool)[] objs)
702     {
703         auto req = DataRequest();
704         req.data["objectIdentifier"] = id;
705         int idx = 0;
706         foreach (obj; objs)
707         {
708             auto trans = "transactions[" ~ to!string(idx) ~ "]";
709             req.data[trans ~ "[type]"] = obj[0];
710             string vals = trans ~ "[value]";
711             if (obj[2])
712             {
713                 vals = vals ~ "[]";
714             }
715 
716             req.data[vals] = obj[1];
717             idx++;
718         }
719 
720         return req;
721     }
722 
723     /**
724      * Data requests
725      */
726     public struct DataRequest
727     {
728         /**
729          * Key/Value pair
730          */
731         public struct KeyValue
732         {
733             // key of item
734             string key;
735 
736             // value of item
737             string value;
738         }
739 
740         // post data
741         string[string] data;
742 
743         // using URL encoded data
744         bool urlEncode = false;
745 
746         // encoded data
747         string encoded;
748 
749         // Raw values to encode
750         string[] raw;
751 
752         // URL encoding function to get key/value pairs to encode
753         KeyValue[] function(string[] input) urlFunction;
754 
755         // encode the internal url as set
756         void encode()
757         {
758             this.encoded = "";
759             if (!this.urlEncode)
760             {
761                 return;
762             }
763 
764             if (this.raw.length > 0 && this.urlFunction !is null)
765             {
766                 string[] results;
767                 foreach (obj; this.urlFunction(this.raw))
768                 {
769                     results ~= format("%s=%s", obj.key, obj.value);
770                 }
771 
772                 this.encoded = join(results, "&");
773             }
774 
775             if (this.urlEncode && this.encoded.length == 0)
776             {
777                 throw new PhabricatorAPIException("URL encoded but no values");
778             }
779         }
780 
781         // Settings that are temporal to request and not initial data requests
782         string[string] temporal;
783     }
784 
785 ///
786 version(PhabUnitTest)
787 {
788     unittest
789     {
790         auto req = DataRequest();
791         req.encode();
792         assert(req.encoded == "");
793         req.urlEncode = true;
794         try
795         {
796             req.encode();
797             assert(false);
798         }
799         catch (PhabricatorAPIException e)
800         {
801             assert(e.msg == "URL encoded but no values");
802         }
803 
804         req.raw ~= "test";
805         try
806         {
807             req.encode();
808             assert(false);
809         }
810         catch (PhabricatorAPIException e)
811         {
812             assert(e.msg == "URL encoded but no values");
813         }
814 
815         DataRequest.KeyValue[] function(string[]) fxn =
816             function DataRequest.KeyValue[](string[] state)
817         {
818             DataRequest.KeyValue[] objs;
819             foreach (obj; state)
820             {
821                 auto test = DataRequest.KeyValue();
822                 test.key = "test";
823                 test.value = obj;
824                 objs ~= test;
825             }
826 
827             return objs;
828         };
829 
830         req.urlFunction = fxn;
831         req.encode();
832         assert(req.encoded == "test=test");
833         req.raw ~= "test2";
834         req.encode();
835         assert(req.encoded == "test=test&test=test2");
836     }
837 }
838 
839     /**
840      * Return paged data
841      */
842     private JSONValue paged(HTTP.Method method,
843                             Category cat,
844                             string call,
845                             DataRequest* req,
846                             StitchFunction stitch)
847     {
848         bool more = true;
849         JSONValue[] results;
850         string afterValue = null;
851         while (more)
852         {
853             req.temporal["order"] = "newest";
854             more = false;
855             if (afterValue !is null)
856             {
857                 req.temporal[AfterKey] = afterValue;
858             }
859 
860             auto current = this.request(method, cat, call, req);
861             results ~= current;
862             if (CursorKey in current[ResultKey])
863             {
864                 auto cursor = current[ResultKey][CursorKey];
865                 if (AfterKey in cursor)
866                 {
867                     auto after = cursor[AfterKey];
868                     if (!after.isNull)
869                     {
870                         afterValue = after.str;
871                         more = true;
872                     }
873                 }
874             }
875         }
876 
877         switch (results.length)
878         {
879             case 0:
880                 return parseJSON("{}");
881             case 1:
882                 return results[0];
883             default:
884                 return stitch(results);
885         }
886     }
887 
888     /**
889      * Make a request
890      */
891     private JSONValue request(HTTP.Method method,
892                               Category cat,
893                               string call,
894                               DataRequest* req)
895     {
896         if (this.token is null || this.token.length == 0 ||
897             this.url is null || this.url.length == 0)
898         {
899             throw new PhabricatorAPIException("url and token are required");
900         }
901 
902         string val = "";
903         try
904         {
905             auto re = DataRequest();
906             if (req !is null)
907             {
908                 re = *req;
909             }
910 
911             re.encode();
912             auto endpoint = this.url ~ "/api/" ~ cat ~ "." ~ call;
913             bool curl = true;
914             version(PhabUnitTest)
915             {
916                 curl = false;
917                 import std.file: readText;
918                 import std.stdio;
919                 writeln(endpoint);
920                 auto text = readText("test/harness.json");
921                 auto test = parseJSON(text);
922                 if (endpoint in test)
923                 {
924                     val = readText("test/" ~ test[endpoint].str ~ ".json");
925                 }
926                 else
927                 {
928                     val = "{}";
929                 }
930             }
931 
932             HTTP client = null;
933             if (curl)
934             {
935                 client = HTTP();
936                 client.operationTimeout = dur!"seconds"(this.timeout);
937                 client.addRequestHeader("ContentType", "application/json");
938             }
939 
940             if (curl)
941             {
942                 auto tokens = re.temporal;
943                 tokens["api.token"] = this.token;
944                 if (re.urlEncode)
945                 {
946                     string encoded = re.encoded;
947                     foreach (token; tokens.byKey())
948                     {
949                         encoded = encoded ~ "&" ~ token ~ "=" ~ tokens[token];
950                     }
951 
952                     mixin(PostEncoded);
953                 }
954                 else
955                 {
956                     string[string] mapped;
957                     foreach (key; re.data.byKey())
958                     {
959                         mapped[key] = re.data[key];
960                     }
961 
962                     foreach (token; tokens.byKey())
963                     {
964                         mapped[token] = tokens[token];
965                     }
966 
967                     mixin(PostMapped);
968                 }
969 
970                 // drop the temporal keys
971                 foreach (key; re.temporal.keys)
972                 {
973                     re.temporal.remove(key);
974                 }
975             }
976 
977             version(PhabUnitTest)
978             {
979                 import std.stdio;
980                 writeln(re.data);
981             }
982 
983             auto json = parseJSON(val);
984             if (ErrorCode in json)
985             {
986                 if (!json[ErrorCode].isNull)
987                 {
988                     throw new PhabricatorAPIException("Response error");
989                 }
990             }
991 
992             return json;
993         }
994         catch (Exception e)
995         {
996             throw new PhabricatorAPIException(e.msg, val);
997         }
998     }
999 }