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 }