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