1 module libpb.driver; 2 3 import std.json; 4 import std.stdio; 5 import std.net.curl; 6 import std.conv : to; 7 import std.string : cmp; 8 import libpb.exceptions; 9 import libpb.serialization; 10 import libpb.deserialization; 11 12 13 private mixin template AuthTokenHeader(alias http, PocketBase pbInstance) 14 { 15 // Must be an instance of HTTP from `std.curl` 16 static assert(__traits(isSame, typeof(http), HTTP)); 17 18 void InitializeAuthHeader() 19 { 20 // Check if the given PocketBase instance as an authToken 21 if(pbInstance.authToken.length > 0) 22 { 23 // Then add the authaorization header 24 http.addRequestHeader("Authorization", pbInstance.getAuthToken()); 25 } 26 } 27 28 } 29 30 public class PocketBase 31 { 32 private string pocketBaseURL; 33 private string authToken; 34 35 /** 36 * Constructs a new PocketBase instance with 37 * the default settings 38 */ 39 this(string pocketBaseURL = "http://127.0.0.1:8090/api/", string authToken = "") 40 { 41 this.pocketBaseURL = pocketBaseURL; 42 this.authToken = authToken; 43 } 44 45 public void setAuthToken(string authToken) 46 { 47 if(cmp(authToken, "") != 0) 48 { 49 this.authToken = authToken; 50 } 51 } 52 53 public string getAuthToken() 54 { 55 return this.authToken; 56 } 57 58 /** 59 * List all of the records in the given table (base collection) 60 * 61 * Params: 62 * table = the table to list from 63 * page = the page to look at (default is 1) 64 * perPage = the number of items to return per page (default is 30) 65 * filter = the predicate to filter by 66 * 67 * Returns: A list of type <code>RecordType</code> 68 */ 69 public RecordType[] listRecords(RecordType)(string table, ulong page = 1, ulong perPage = 30, string filter = "") 70 { 71 return listRecords_internal!(RecordType)(table, page, perPage, filter, false); 72 } 73 74 /** 75 * List all of the records in the given table (auth collection) 76 * 77 * Params: 78 * table = the table to list from 79 * page = the page to look at (default is 1) 80 * perPage = the number of items to return per page (default is 30) 81 * filter = the predicate to filter by 82 * 83 * Returns: A list of type <code>RecordType</code> 84 */ 85 public RecordType[] listRecordsAuth(RecordType)(string table, ulong page = 1, ulong perPage = 30, string filter = "") 86 { 87 return listRecords_internal!(RecordType)(table, page, perPage, filter, true); 88 } 89 90 /** 91 * List all of the records in the given table (internals) 92 * 93 * Params: 94 * table = the table to list from 95 * page = the page to look at (default is 1) 96 * perPage = the number of items to return per page (default is 30) 97 * filter = the predicate to filter by 98 * isAuthCollection = true if this is an auth collection, false 99 * for base collection 100 * 101 * Returns: A list of type <code>RecordType</code> 102 */ 103 private RecordType[] listRecords_internal(RecordType)(string table, ulong page = 1, ulong perPage = 30, string filter = "", bool isAuthCollection = false) 104 { 105 // Set authorization token if setup 106 HTTP httpSettings = HTTP(); 107 mixin AuthTokenHeader!(httpSettings, this); 108 InitializeAuthHeader(); 109 110 RecordType[] recordsOut; 111 112 // Compute the query string 113 string queryStr = "page="~to!(string)(page)~"&perPage="~to!(string)(perPage); 114 115 // If there is a filter then perform the needed escaping 116 if(cmp(filter, "") != 0) 117 { 118 // For the filter, make sure to add URL escaping to the `filter` parameter 119 import etc.c.curl : curl_escape; 120 import std.string : toStringz, fromStringz; 121 char* escapedParameter = curl_escape(toStringz(filter), cast(int)filter.length); 122 if(escapedParameter is null) 123 { 124 debug(dbg) 125 { 126 writeln("Invalid return from curl_easy_escape"); 127 } 128 throw new NetworkException(); 129 } 130 131 // Convert back to D-string (the filter) 132 filter = cast(string)fromStringz(escapedParameter); 133 } 134 135 // Append the filter 136 queryStr ~= cmp(filter, "") == 0 ? "" : "&filter="~filter; 137 138 try 139 { 140 string responseData = cast(string)get(pocketBaseURL~"collections/"~table~"/records?"~queryStr, httpSettings); 141 JSONValue responseJSON = parseJSON(responseData); 142 JSONValue[] returnedItems = responseJSON["items"].array(); 143 144 foreach(JSONValue returnedItem; returnedItems) 145 { 146 // If this is an authable record (meaning it has email, password and passwordConfirm) 147 // well then the latter two will not be returned so fill them in. Secondly, the email 148 // will only be returned if `emailVisibility` is true. 149 if(isAuthCollection) 150 { 151 returnedItem["password"] = ""; 152 returnedItem["passwordConfirm"] = ""; 153 154 // If email is invisible make a fake field to prevent crash 155 if(!returnedItem["emailVisibility"].boolean()) 156 { 157 returnedItem["email"] = ""; 158 } 159 } 160 161 recordsOut ~= fromJSON!(RecordType)(returnedItem); 162 } 163 164 return recordsOut; 165 } 166 catch(HTTPStatusException e) 167 { 168 if(e.status == 403) 169 { 170 throw new NotAuthorized(table, null); 171 } 172 else 173 { 174 throw new NetworkException(); 175 } 176 } 177 catch(CurlException e) 178 { 179 debug(dbg) 180 { 181 writeln("curl"); 182 writeln(e); 183 } 184 185 throw new NetworkException(); 186 } 187 catch(JSONException e) 188 { 189 throw new PocketBaseParsingException(); 190 } 191 } 192 193 /** 194 * Creates a record in the given authentication table 195 * 196 * Params: 197 * table = the table to create the record in 198 * item = The Record to create 199 * 200 * Returns: An instance of the created <code>RecordType</code> 201 */ 202 public RecordType createRecordAuth(string, RecordType)(string table, RecordType item) 203 { 204 mixin isAuthable!(RecordType); 205 206 return createRecord_internal(table, item, true); 207 } 208 209 /** 210 * Creates a record in the given base table 211 * 212 * Params: 213 * table = the table to create the record in 214 * item = The Record to create 215 * 216 * Returns: An instance of the created <code>RecordType</code> 217 */ 218 public RecordType createRecord(string, RecordType)(string table, RecordType item) 219 { 220 return createRecord_internal(table, item, false); 221 } 222 223 /** 224 * Creates a record in the given table (internal method) 225 * 226 * Params: 227 * table = the table to create the record in 228 * item = The Record to create 229 * isAuthCollection = whether or not this collection is auth or not (base) 230 * 231 * Returns: An instance of the created <code>RecordType</code> 232 */ 233 private RecordType createRecord_internal(string, RecordType)(string table, RecordType item, bool isAuthCollection = false) 234 { 235 idAbleCheck(item); 236 237 RecordType recordOut; 238 239 // Set authorization token if setup 240 HTTP httpSettings = HTTP(); 241 mixin AuthTokenHeader!(httpSettings, this); 242 InitializeAuthHeader(); 243 244 // Set the content type 245 httpSettings.addRequestHeader("Content-Type", "application/json"); 246 247 // Serialize the record instance 248 JSONValue serialized = serializeRecord(item); 249 250 try 251 { 252 string responseData = cast(string)post(pocketBaseURL~"collections/"~table~"/records", serialized.toString(), httpSettings); 253 JSONValue responseJSON = parseJSON(responseData); 254 255 // On creation of a record in an "auth" collection the email visibility 256 // will initially be false, therefore fill in a blank for it temporarily 257 // now as to not make `fromJSON` crash when it sees an email field in 258 // a struct and tries to look the the JSON key "email" when it isn't present 259 // 260 // A password is never returned (so `password` and `passwordConfirm` will be left out) 261 // 262 // The above are all assumed to be strings, if not then a runtime error will occur 263 // See (issue #3) 264 if(isAuthCollection) 265 { 266 responseJSON["email"] = ""; 267 responseJSON["password"] = ""; 268 responseJSON["passwordConfirm"] = ""; 269 } 270 271 recordOut = fromJSON!(RecordType)(responseJSON); 272 273 return recordOut; 274 } 275 catch(HTTPStatusException e) 276 { 277 debug(dbg) 278 { 279 writeln("createRecord_internal: "~e.toString()); 280 } 281 282 if(e.status == 403) 283 { 284 throw new NotAuthorized(table, item.id); 285 } 286 else if(e.status == 400) 287 { 288 throw new ValidationRequired(table, item.id); 289 } 290 else 291 { 292 // TODO: Fix this 293 throw new NetworkException(); 294 } 295 } 296 catch(CurlException e) 297 { 298 throw new NetworkException(); 299 } 300 catch(JSONException e) 301 { 302 throw new PocketBaseParsingException(); 303 } 304 } 305 306 /** 307 * Authenticates on the given auth table with the provided 308 * credentials, returning a JWT token in the reference parameter. 309 * Finally returning the record of the authenticated user. 310 * 311 * Params: 312 * table = the auth collection to use 313 * identity = the user's identity 314 * password = the user's password 315 * token = the variable to return into 316 * 317 * Returns: An instance of `RecordType` 318 */ 319 public RecordType authWithPassword(RecordType)(string table, string identity, string password, ref string token) 320 { 321 mixin isAuthable!(RecordType); 322 323 RecordType recordOut; 324 325 // Set the content type 326 HTTP httpSettings = HTTP(); 327 httpSettings.addRequestHeader("Content-Type", "application/json"); 328 329 // Construct the authentication record 330 JSONValue authRecord; 331 authRecord["identity"] = identity; 332 authRecord["password"] = password; 333 334 try 335 { 336 string responseData = cast(string)post(pocketBaseURL~"collections/"~table~"/auth-with-password", authRecord.toString(), httpSettings); 337 JSONValue responseJSON = parseJSON(responseData); 338 JSONValue recordResponse = responseJSON["record"]; 339 340 // In the case we are doing auth, we won't get password, passwordConfirm sent back 341 // set them to empty 342 recordResponse["password"] = ""; 343 recordResponse["passwordConfirm"] = ""; 344 345 // If email is invisible make a fake field to prevent crash 346 if(!recordResponse["emailVisibility"].boolean()) 347 { 348 recordResponse["email"] = ""; 349 } 350 351 352 recordOut = fromJSON!(RecordType)(recordResponse); 353 354 // Store the token 355 token = responseJSON["token"].str(); 356 357 return recordOut; 358 } 359 catch(HTTPStatusException e) 360 { 361 if(e.status == 400) 362 { 363 // TODO: Update this error 364 throw new NotAuthorized(table, null); 365 } 366 else 367 { 368 // TODO: Fix this 369 throw new NetworkException(); 370 } 371 } 372 catch(CurlException e) 373 { 374 throw new NetworkException(); 375 } 376 catch(JSONException e) 377 { 378 throw new PocketBaseParsingException(); 379 } 380 } 381 382 /** 383 * View the given record by id (base collections) 384 * 385 * Params: 386 * table = the table to lookup the record in 387 * id = the id to lookup the record by 388 * 389 * Returns: The found record of type <code>RecordType</code> 390 */ 391 public RecordType viewRecord(RecordType)(string table, string id) 392 { 393 return viewRecord_internal!(RecordType)(table, id, false); 394 } 395 396 397 /** 398 * View the given record by id (auth collections) 399 * 400 * Params: 401 * table = the table to lookup the record in 402 * id = the id to lookup the record by 403 * 404 * Returns: The found record of type <code>RecordType</code> 405 */ 406 public RecordType viewRecordAuth(RecordType)(string table, string id) 407 { 408 return viewRecord_internal!(RecordType)(table, id, true); 409 } 410 411 /** 412 * View the given record by id (internal) 413 * 414 * Params: 415 * table = the table to lookup the record in 416 * id = the id to lookup the record by 417 * isAuthCollection = true if this is an auth collection, false 418 * for base collection 419 * 420 * Returns: The found record of type <code>RecordType</code> 421 */ 422 private RecordType viewRecord_internal(RecordType)(string table, string id, bool isAuthCollection) 423 { 424 RecordType recordOut; 425 426 // Set authorization token if setup 427 HTTP httpSettings = HTTP(); 428 mixin AuthTokenHeader!(httpSettings, this); 429 InitializeAuthHeader(); 430 431 try 432 { 433 string responseData = cast(string)get(pocketBaseURL~"collections/"~table~"/records/"~id, httpSettings); 434 JSONValue responseJSON = parseJSON(responseData); 435 436 // If this is an authable record (meaning it has email, password and passwordConfirm) 437 // well then the latter two will not be returned so fill them in. Secondly, the email 438 // will only be returned if `emailVisibility` is true. 439 if(isAuthCollection) 440 { 441 responseJSON["password"] = ""; 442 responseJSON["passwordConfirm"] = ""; 443 444 // If email is invisible make a fake field to prevent crash 445 if(!responseJSON["emailVisibility"].boolean()) 446 { 447 responseJSON["email"] = ""; 448 } 449 } 450 451 recordOut = fromJSON!(RecordType)(responseJSON); 452 453 return recordOut; 454 } 455 catch(HTTPStatusException e) 456 { 457 if(e.status == 404) 458 { 459 throw new RecordNotFoundException(table, id); 460 } 461 else 462 { 463 // TODO: Fix this 464 throw new NetworkException(); 465 } 466 } 467 catch(CurlException e) 468 { 469 throw new NetworkException(); 470 } 471 catch(JSONException e) 472 { 473 throw new PocketBaseParsingException(); 474 } 475 } 476 477 /** 478 * Updates the given record in the given table, returning the 479 * updated record 480 * 481 * Params: 482 * table = tabe table to update the record in 483 * item = the record of type <code>RecordType</code> to update 484 * 485 * Returns: The updated <code>RecordType</code> 486 */ 487 public RecordType updateRecord(string, RecordType)(string table, RecordType item) 488 { 489 idAbleCheck(item); 490 491 RecordType recordOut; 492 493 // Set authorization token if setup 494 HTTP httpSettings = HTTP(); 495 mixin AuthTokenHeader!(httpSettings, this); 496 InitializeAuthHeader(); 497 498 // Set the content type 499 httpSettings.addRequestHeader("Content-Type", "application/json"); 500 501 // Serialize the record instance 502 JSONValue serialized = serializeRecord(item); 503 504 try 505 { 506 string responseData = cast(string)patch(pocketBaseURL~"collections/"~table~"/records/"~item.id, serialized.toString(), httpSettings); 507 JSONValue responseJSON = parseJSON(responseData); 508 509 recordOut = fromJSON!(RecordType)(responseJSON); 510 511 return recordOut; 512 } 513 catch(HTTPStatusException e) 514 { 515 if(e.status == 404) 516 { 517 throw new RecordNotFoundException(table, item.id); 518 } 519 else if(e.status == 403) 520 { 521 throw new NotAuthorized(table, item.id); 522 } 523 else if(e.status == 400) 524 { 525 throw new ValidationRequired(table, item.id); 526 } 527 else 528 { 529 // TODO: Fix this 530 throw new NetworkException(); 531 } 532 } 533 catch(CurlException e) 534 { 535 throw new NetworkException(); 536 } 537 catch(JSONException e) 538 { 539 throw new PocketBaseParsingException(); 540 } 541 } 542 543 /** 544 * Deletes the provided record by id from the given table 545 * 546 * Params: 547 * table = the table to delete the record from 548 * id = the id of the record to delete 549 */ 550 public void deleteRecord(string table, string id) 551 { 552 // Set authorization token if setup 553 HTTP httpSettings = HTTP(); 554 mixin AuthTokenHeader!(httpSettings, this); 555 InitializeAuthHeader(); 556 557 try 558 { 559 del(pocketBaseURL~"collections/"~table~"/records/"~id, httpSettings); 560 } 561 catch(HTTPStatusException e) 562 { 563 if(e.status == 404) 564 { 565 throw new RecordNotFoundException(table, id); 566 } 567 else 568 { 569 // TODO: Fix this 570 throw new NetworkException(); 571 } 572 } 573 catch(CurlException e) 574 { 575 throw new NetworkException(); 576 } 577 } 578 579 /** 580 * Deletes the provided record from the given table 581 * 582 * Params: 583 * table = the table to delete from 584 * record = the record of type <code>RecordType</code> to delete 585 */ 586 public void deleteRecord(string, RecordType)(string table, RecordType record) 587 { 588 idAbleCheck(record); 589 deleteRecord(table, record.id); 590 } 591 592 mixin template MemberAndType(alias record, alias typeEnforce, string memberName) 593 { 594 static if(__traits(hasMember, record, memberName)) 595 { 596 static if(__traits(isSame, typeof(mixin("record."~memberName)), typeEnforce)) 597 { 598 599 } 600 else 601 { 602 pragma(msg, "Member '"~memberName~"' not of type '"~typeEnforce~"'"); 603 static assert(false); 604 } 605 } 606 else 607 { 608 pragma(msg, "Record does not have member '"~memberName~"'"); 609 static assert(false); 610 } 611 } 612 613 private static void isAuthable(RecordType)(RecordType record) 614 { 615 mixin MemberAndType!(record, string, "email"); 616 mixin MemberAndType!(record, string, "password"); 617 mixin MemberAndType!(record, string, "passwordConfirm"); 618 } 619 620 private static void idAbleCheck(RecordType)(RecordType record) 621 { 622 static if(__traits(hasMember, record, "id")) 623 { 624 static if(__traits(isSame, typeof(record.id), string)) 625 { 626 // Do nothing as it is a-okay 627 } 628 else 629 { 630 // Must be a string 631 pragma(msg, "The `id` field of the record provided must be of type string"); 632 static assert(false); 633 } 634 } 635 else 636 { 637 // An id field is required (TODO: ensure not a function identifier) 638 pragma(msg, "The provided record must have a `id` field"); 639 static assert(false); 640 } 641 } 642 643 // TODO: Implement the streaming functionality 644 private void stream(string table) 645 { 646 647 } 648 } 649 650 unittest 651 { 652 import core.thread : Thread, dur; 653 import std.string : cmp; 654 655 PocketBase pb = new PocketBase(); 656 657 struct Person 658 { 659 string id; 660 string name; 661 int age; 662 } 663 664 Person p1 = Person(); 665 p1.name = "Tristan Gonzales"; 666 p1.age = 23; 667 668 Person recordStored = pb.createRecord("dummy", p1); 669 pb.deleteRecord("dummy", recordStored.id); 670 671 672 recordStored = pb.createRecord("dummy", p1); 673 Thread.sleep(dur!("seconds")(3)); 674 recordStored.age = 46; 675 recordStored = pb.updateRecord("dummy", recordStored); 676 assert(recordStored.age == 46); 677 Thread.sleep(dur!("seconds")(3)); 678 679 Person recordFetched = pb.viewRecord!(Person)("dummy", recordStored.id); 680 assert(recordFetched.age == 46); 681 assert(cmp(recordFetched.name, "Tristan Gonzales") == 0); 682 assert(cmp(recordFetched.id, recordStored.id) == 0); 683 684 pb.deleteRecord("dummy", recordStored); 685 686 Person[] people = [Person(), Person()]; 687 people[0].name = "Abby"; 688 people[1].name = "Becky"; 689 690 people[0] = pb.createRecord("dummy", people[0]); 691 people[1] = pb.createRecord("dummy", people[1]); 692 693 Person[] returnedPeople = pb.listRecords!(Person)("dummy"); 694 foreach(Person returnedPerson; returnedPeople) 695 { 696 debug(dbg) 697 { 698 writeln(returnedPerson); 699 } 700 pb.deleteRecord("dummy", returnedPerson); 701 } 702 703 try 704 { 705 recordFetched = pb.viewRecord!(Person)("dummy", people[0].id); 706 assert(false); 707 } 708 catch(RecordNotFoundException e) 709 { 710 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 711 } 712 catch(Exception e) 713 { 714 assert(false); 715 } 716 717 try 718 { 719 recordFetched = pb.updateRecord("dummy", people[0]); 720 assert(false); 721 } 722 catch(RecordNotFoundException e) 723 { 724 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 725 } 726 catch(Exception e) 727 { 728 assert(false); 729 } 730 731 try 732 { 733 pb.deleteRecord("dummy", people[0]); 734 assert(false); 735 } 736 catch(RecordNotFoundException e) 737 { 738 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 739 } 740 catch(Exception e) 741 { 742 assert(false); 743 } 744 } 745 746 unittest 747 { 748 import core.thread : Thread, dur; 749 import std.string : cmp; 750 751 PocketBase pb = new PocketBase(); 752 753 struct Person 754 { 755 string id; 756 string email; 757 string username; 758 string password; 759 string passwordConfirm; 760 string name; 761 int age; 762 } 763 764 // Set the password to use 765 string passwordToUse = "bigbruh1111"; 766 767 Person p1; 768 p1.email = "deavmi@redxen.eu"; 769 p1.username = "deavmi"; 770 p1.password = passwordToUse; 771 p1.passwordConfirm = passwordToUse; 772 p1.name = "Tristaniha"; 773 p1.age = 29; 774 775 p1 = pb.createRecordAuth("dummy_auth", p1); 776 777 778 Person[] people = pb.listRecordsAuth!(Person)("dummy_auth", 1, 30, "(id='"~p1.id~"')"); 779 assert(people.length == 1); 780 781 // Ensure we get our person back 782 assert(cmp(people[0].name, p1.name) == 0); 783 assert(people[0].age == p1.age); 784 // assert(cmp(people[0].email, p1.email) == 0); 785 786 787 Person person = pb.viewRecordAuth!(Person)("dummy_auth", p1.id); 788 789 // Ensure we get our person back 790 assert(cmp(people[0].name, p1.name) == 0); 791 assert(people[0].age == p1.age); 792 // assert(cmp(people[0].email, p1.email) == 0); 793 794 795 796 string tokenIn; 797 Person authPerson = pb.authWithPassword!(Person)("dummy_auth", p1.username, passwordToUse, tokenIn); 798 799 // Ensure a non-empty token 800 assert(cmp(tokenIn, "") != 0); 801 writeln("Token: "~tokenIn); 802 803 // Ensure we get our person back 804 assert(cmp(authPerson.name, p1.name) == 0); 805 assert(authPerson.age == p1.age); 806 assert(cmp(authPerson.email, p1.email) == 0); 807 808 // Delete the record 809 pb.deleteRecord("dummy_auth", p1); 810 } 811 812 unittest 813 { 814 import core.thread : Thread, dur; 815 import std.string : cmp; 816 817 PocketBase pb = new PocketBase(); 818 819 struct Person 820 { 821 string id; 822 string name; 823 int age; 824 } 825 826 Person p1 = Person(); 827 p1.name = "Tristan Gonzales"; 828 p1.age = 23; 829 830 Person p2 = Person(); 831 p2.name = p1.name~"2"; 832 p2.age = p1.age; 833 834 p1 = pb.createRecord("dummy", p1); 835 p2 = pb.createRecord("dummy", p2); 836 837 Person[] people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p1.id~"')"); 838 assert(people.length == 1); 839 assert(cmp(people[0].id, p1.id) == 0); 840 841 pb.deleteRecord("dummy", p1); 842 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p1.id~"')"); 843 assert(people.length == 0); 844 845 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p2.id~"' && age=24)"); 846 assert(people.length == 0); 847 848 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p2.id~"' && age=23)"); 849 assert(people.length == 1 && cmp(people[0].id, p2.id) == 0); 850 851 pb.deleteRecord("dummy", p2); 852 }