1pub mod dsl;
77pub mod query_generator;
78
79use std::marker::PhantomData;
80
81pub use dsl::*;
85
86pub use dsl::prelude;
89
90use reqwest::{Client as ReqwestClient, StatusCode};
91use serde::{Deserialize, Serialize};
92use thiserror::Error;
93
94#[derive(Debug, Clone)]
123pub struct Client {
124 client: ReqwestClient,
125 url: reqwest::Url,
126 api_key: Option<String>,
127}
128
129pub type HelixDBClient = Client;
131
132#[derive(Debug, Error)]
134pub enum HelixError {
135 #[error("Error communicating with server: {0}")]
138 ReqwestError(#[from] reqwest::Error),
139 #[error("Got Error from server: {details}")]
143 RemoteError {
144 details: String,
146 },
147 #[error("Error serializing data: {0}")]
149 SerializationError(#[from] sonic_rs::Error),
150 #[error("Invalid URL: {0}")]
153 InvalidURL(String),
154}
155
156impl Client {
157 pub fn new(url: Option<&str>) -> Result<Self, HelixError> {
169 let url = reqwest::Url::parse(url.unwrap_or("http://localhost:6969"))
172 .map_err(|e| HelixError::InvalidURL(e.to_string()))?
173 .join("/v1/query")
174 .map_err(|e| HelixError::InvalidURL(e.to_string()))?;
175 Ok(Self {
176 client: ReqwestClient::new(),
177 url,
178 api_key: None,
179 })
180 }
181
182 pub fn with_api_key(mut self, api_key: Option<&str>) -> Self {
187 self.api_key = api_key.map(|key| key.to_string());
188 self
189 }
190
191 pub fn query<R: for<'de> Deserialize<'de>>(&self) -> QueryBuilder<'_, '_, R> {
215 QueryBuilder::new(self)
216 }
217}
218
219pub struct QueryBuilder<'hlx, 'a, R> {
230 client: &'hlx HelixDBClient,
231 query_type: QueryType,
232 headers: [Option<(&'a str, &'a str)>; 4],
233 body: Option<Vec<u8>>,
234 _phantom: PhantomData<R>,
235}
236
237#[derive(Default)]
243pub(crate) enum QueryType {
244 Stored(String),
246 Dynamic(DynamicQueryRequest),
248 #[default]
250 Empty,
251}
252
253impl<'hlx, 'a, R> QueryBuilder<'hlx, 'a, R> {
254 #[must_use]
258 pub fn new(client: &'hlx HelixDBClient) -> Self {
259 let mut headers = [None; 4];
260 headers[0] = Some(("Content-Type", "application/json"));
261 Self {
262 client,
263 query_type: QueryType::default(),
264 headers,
265 body: None,
266 _phantom: PhantomData,
267 }
268 }
269
270 #[must_use]
274 pub fn writer_only(mut self) -> Self {
275 self.headers[1] = Some(("x-helix-require-writer", "true"));
276 self
277 }
278
279 #[must_use]
283 pub fn warm_only(mut self) -> Self {
284 self.headers[2] = Some(("x-helix-warm", "true"));
285 self
286 }
287
288 #[must_use]
292 pub fn should_await_durability(mut self, should: bool) -> Self {
293 self.headers[3] = Some((
294 "x-helix-await-durable",
295 if should { "true" } else { "false" },
296 ));
297 self
298 }
299
300 #[must_use]
311 pub fn body<T: Serialize + Sync>(mut self, data: &T) -> Result<Self, HelixError> {
312 self.body = Some(sonic_rs::to_vec(data)?);
313 Ok(self)
314 }
315
316 #[must_use]
321 pub fn stored(mut self, query_name: String) -> QueryRequest<'hlx, 'a, R> {
322 self.query_type = QueryType::Stored(query_name);
323 QueryRequest { request: self }
324 }
325
326 #[must_use]
332 pub fn dynamic(mut self, query: DynamicQueryRequest) -> QueryRequest<'hlx, 'a, R> {
333 self.query_type = QueryType::Dynamic(query);
334 QueryRequest { request: self }
335 }
336}
337
338pub struct QueryRequest<'hlx, 'a, R> {
343 request: QueryBuilder<'hlx, 'a, R>,
344}
345
346impl<'hlx, 'a, R: for<'de> Deserialize<'de>> QueryRequest<'hlx, 'a, R> {
347 pub async fn send(self) -> Result<R, HelixError> {
378 let query_request = self.request;
379 let (url, body) = match query_request.query_type {
380 QueryType::Dynamic(query) => ("/v1/query".to_string(), Some(sonic_rs::to_vec(&query)?)),
381 QueryType::Stored(name) => (format!("/v1/query/{name}"), query_request.body),
382 QueryType::Empty => {
383 unreachable!("send() is only reachable after stored() or dynamic() sets query_type")
384 }
385 };
386 let url = query_request
387 .client
388 .url
389 .join(&url)
390 .map_err(|e| HelixError::InvalidURL(e.to_string()))?;
391
392 let mut request = query_request.client.client.post(url);
393
394 for (k, v) in query_request.headers.into_iter().flatten() {
395 request = request.header(k, v);
396 }
397 if let Some(ref api_key) = query_request.client.api_key {
398 request = request.bearer_auth(api_key);
399 }
400 if let Some(body) = body {
401 request = request.body(body);
402 }
403
404 let response = request.send().await?;
405
406 match response.status() {
407 StatusCode::OK => {
408 let bytes = response.bytes().await?;
409 sonic_rs::from_slice::<R>(&bytes).map_err(Into::into)
410 }
411 code => match response.text().await {
412 Ok(t) => Err(HelixError::RemoteError { details: t }),
413 Err(_) => match code.canonical_reason() {
414 Some(r) => Err(HelixError::RemoteError {
415 details: r.to_string(),
416 }),
417 None => Err(HelixError::RemoteError {
418 details: format!("unkown error with code: {code}"),
419 }),
420 },
421 },
422 }
423 }
424}
425
426extern crate self as helix_db;
427
428#[cfg(test)]
429mod tests {
430 use helix_db::dsl::prelude::*;
431 use std::collections::BTreeMap;
432
433 #[register]
434 fn query1(name: String) {
435 read_batch()
437 .var_as("user", g().n_where(SourcePredicate::eq("username", name)))
438 .var_as(
439 "friends",
440 g().n(NodeRef::var("user"))
441 .out(Some("FOLLOWS"))
442 .dedup()
443 .limit(100),
444 )
445 .returning(["user", "friends"])
446 }
447
448 #[test]
449 fn query1_builds_dynamic_request() {
450 let query = query1(String::from("alice"));
452
453 assert!(matches!(query.request_type, DynamicQueryRequestType::Read));
454 assert_eq!(query.query_name.as_deref(), Some("query1"));
455 let params = query.parameters.expect("parameters present");
456 assert!(matches!(
457 params.get("name"),
458 Some(DynamicQueryValue::String(s)) if s == "alice"
459 ));
460 }
461
462 #[test]
463 fn dynamic_request_serializes_query_name() {
464 let unnamed = DynamicQueryRequest::read(
465 read_batch()
466 .var_as("count", g().n_with_label("User").count())
467 .returning(["count"]),
468 )
469 .to_json_string()
470 .expect("serialize unnamed dynamic request");
471 assert!(
472 unnamed.contains(r#""query_name":null"#),
473 "unnamed request should serialize query_name=null: {unnamed}"
474 );
475
476 let named = DynamicQueryRequest::read(read_batch())
477 .with_query_name("find_users")
478 .to_json_string()
479 .expect("serialize named dynamic request");
480 assert!(
481 named.contains(r#""query_name":"find_users""#),
482 "named request should serialize query_name: {named}"
483 );
484 }
485
486 #[register]
489 fn q_bool(flag: bool) {
490 read_batch()
491 .var_as("v", g().n_where(SourcePredicate::eq("field", flag)))
492 .returning(["v"])
493 }
494 #[register]
495 fn q_i64(num: i64) {
496 read_batch()
497 .var_as("v", g().n_where(SourcePredicate::eq("field", num)))
498 .returning(["v"])
499 }
500 #[register]
501 fn q_f64(x: f64) {
502 read_batch()
503 .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
504 .returning(["v"])
505 }
506 #[register]
507 fn q_f32(x: f32) {
508 read_batch()
509 .var_as("v", g().n_where(SourcePredicate::eq("field", x)))
510 .returning(["v"])
511 }
512 #[register]
513 fn q_datetime(ts: DateTime) {
514 read_batch()
515 .var_as("v", g().n_where(SourcePredicate::eq("field", ts)))
516 .returning(["v"])
517 }
518 #[register]
519 fn q_value(val: ParamValue) {
520 read_batch()
521 .var_as("v", g().n_where(SourcePredicate::eq("field", val)))
522 .returning(["v"])
523 }
524 #[register]
525 fn q_object(obj: ParamObject) {
526 read_batch()
527 .var_as("v", g().n_where(SourcePredicate::eq("field", obj)))
528 .returning(["v"])
529 }
530 #[register]
531 fn q_array(items: Vec<String>) {
532 read_batch()
533 .var_as("v", g().n_where(SourcePredicate::eq("field", items)))
534 .returning(["v"])
535 }
536 #[register]
537 fn q_map(map: BTreeMap<String, String>) {
538 read_batch()
539 .var_as("v", g().n_where(SourcePredicate::eq("field", map)))
540 .returning(["v"])
541 }
542 #[register]
543 #[allow(unused_variables)] fn q_bytes(blob: Vec<u8>) {
545 read_batch()
546 .var_as("v", g().n_where(SourcePredicate::eq("field", blob)))
547 .returning(["v"])
548 }
549
550 #[test]
551 fn param_types_coerce_correctly() {
552 let r = q_bool(true);
554 assert!(matches!(r.request_type, DynamicQueryRequestType::Read));
555 assert!(matches!(
556 r.parameters.as_ref().unwrap().get("flag"),
557 Some(DynamicQueryValue::Bool(true))
558 ));
559 assert!(matches!(
560 r.parameter_types.as_ref().unwrap().get("flag"),
561 Some(QueryParamType::Bool)
562 ));
563
564 let r = q_i64(7);
566 assert!(matches!(
567 r.parameters.as_ref().unwrap().get("num"),
568 Some(DynamicQueryValue::I64(7))
569 ));
570 assert!(matches!(
571 r.parameter_types.as_ref().unwrap().get("num"),
572 Some(QueryParamType::I64)
573 ));
574
575 let r = q_f64(1.5);
577 assert!(matches!(
578 r.parameters.as_ref().unwrap().get("x"),
579 Some(DynamicQueryValue::F64(v)) if *v == 1.5
580 ));
581 assert!(matches!(
582 r.parameter_types.as_ref().unwrap().get("x"),
583 Some(QueryParamType::F64)
584 ));
585
586 let r = q_f32(1.5f32);
588 assert!(matches!(
589 r.parameters.as_ref().unwrap().get("x"),
590 Some(DynamicQueryValue::F32(v)) if *v == 1.5f32
591 ));
592 assert!(matches!(
593 r.parameter_types.as_ref().unwrap().get("x"),
594 Some(QueryParamType::F32)
595 ));
596
597 let r = q_datetime(DateTime::from_millis(0));
599 let expected = DateTime::from_millis(0).to_rfc3339().unwrap();
600 assert!(matches!(
601 r.parameters.as_ref().unwrap().get("ts"),
602 Some(DynamicQueryValue::String(s)) if *s == expected
603 ));
604 assert!(matches!(
605 r.parameter_types.as_ref().unwrap().get("ts"),
606 Some(QueryParamType::DateTime)
607 ));
608
609 let r = q_value(PropertyValue::I64(5));
611 assert!(matches!(
612 r.parameters.as_ref().unwrap().get("val"),
613 Some(DynamicQueryValue::I64(5))
614 ));
615 assert!(matches!(
616 r.parameter_types.as_ref().unwrap().get("val"),
617 Some(QueryParamType::Value)
618 ));
619
620 let mut obj = BTreeMap::new();
622 obj.insert("k".to_string(), PropertyValue::String("x".to_string()));
623 let r = q_object(obj);
624 assert!(matches!(
625 r.parameters.as_ref().unwrap().get("obj"),
626 Some(DynamicQueryValue::Object(_))
627 ));
628 assert!(matches!(
629 r.parameter_types.as_ref().unwrap().get("obj"),
630 Some(QueryParamType::Object)
631 ));
632
633 let r = q_array(vec!["a".to_string(), "b".to_string()]);
635 match r.parameters.as_ref().unwrap().get("items") {
636 Some(DynamicQueryValue::Array(items)) => {
637 assert_eq!(items.len(), 2);
638 assert!(matches!(&items[0], DynamicQueryValue::String(s) if s == "a"));
639 assert!(matches!(&items[1], DynamicQueryValue::String(s) if s == "b"));
640 }
641 other => panic!("expected array, got {other:?}"),
642 }
643 assert!(matches!(
644 r.parameter_types.as_ref().unwrap().get("items"),
645 Some(QueryParamType::Array(inner)) if matches!(**inner, QueryParamType::String)
646 ));
647
648 let mut map = BTreeMap::new();
650 map.insert("k".to_string(), "v".to_string());
651 let r = q_map(map);
652 assert!(matches!(
653 r.parameters.as_ref().unwrap().get("map"),
654 Some(DynamicQueryValue::Object(_))
655 ));
656 assert!(matches!(
657 r.parameter_types.as_ref().unwrap().get("map"),
658 Some(QueryParamType::Object)
659 ));
660 }
661
662 #[test]
663 #[should_panic(expected = "failed to coerce parameter")]
664 fn bytes_param_panics_on_dynamic_call() {
665 let _ = q_bytes(vec![1, 2, 3]);
668 }
669
670 #[test]
673 fn predicate_literal_json_is_unchanged() {
674 assert_eq!(
675 sonic_rs::to_string(&Predicate::eq("username", "alice")).unwrap(),
676 r#"{"Eq":["username",{"String":"alice"}]}"#
677 );
678 assert_eq!(
679 sonic_rs::to_string(&Predicate::gt("score", 10i64)).unwrap(),
680 r#"{"Gt":["score",{"I64":10}]}"#
681 );
682 assert_eq!(
683 sonic_rs::to_string(&Predicate::between("age", 18i64, 65i64)).unwrap(),
684 r#"{"Between":["age",{"I64":18},{"I64":65}]}"#
685 );
686 }
687
688 #[test]
689 fn predicate_param_json_uses_expr_variants() {
690 assert_eq!(
691 sonic_rs::to_string(&Predicate::eq("username", Expr::param("name"))).unwrap(),
692 r#"{"EqExpr":["username",{"Param":"name"}]}"#
693 );
694 assert_eq!(
695 sonic_rs::to_string(&Predicate::lte("score", Expr::param("max"))).unwrap(),
696 r#"{"LteExpr":["score",{"Param":"max"}]}"#
697 );
698 assert_eq!(
699 sonic_rs::to_string(&Predicate::between("age", Expr::param("lo"), 65i64)).unwrap(),
700 r#"{"BetweenExpr":["age",{"Param":"lo"},{"Constant":{"I64":65}}]}"#
701 );
702 }
703
704 #[test]
705 fn predicate_json_round_trips() {
706 for predicate in [
707 Predicate::eq("username", "alice"),
708 Predicate::eq("username", Expr::param("name")),
709 Predicate::between("age", Expr::param("lo"), 65i64),
710 ] {
711 let json = sonic_rs::to_string(&predicate).unwrap();
712 let back: Predicate = sonic_rs::from_str(&json).unwrap();
713 assert_eq!(predicate, back);
714 }
715 }
716
717 #[test]
720 fn source_predicate_literal_json_is_unchanged() {
721 assert_eq!(
722 sonic_rs::to_string(&SourcePredicate::eq("username", "alice")).unwrap(),
723 r#"{"Eq":["username",{"String":"alice"}]}"#
724 );
725 assert_eq!(
726 sonic_rs::to_string(&SourcePredicate::gt("score", 10i64)).unwrap(),
727 r#"{"Gt":["score",{"I64":10}]}"#
728 );
729 assert_eq!(
730 sonic_rs::to_string(&SourcePredicate::between("age", 18i64, 65i64)).unwrap(),
731 r#"{"Between":["age",{"I64":18},{"I64":65}]}"#
732 );
733 }
734
735 #[test]
736 fn source_predicate_param_json_uses_expr_variants() {
737 assert_eq!(
738 sonic_rs::to_string(&SourcePredicate::eq("username", Expr::param("name"))).unwrap(),
739 r#"{"EqExpr":["username",{"Param":"name"}]}"#
740 );
741 assert_eq!(
742 sonic_rs::to_string(&SourcePredicate::lte("score", Expr::param("max"))).unwrap(),
743 r#"{"LteExpr":["score",{"Param":"max"}]}"#
744 );
745 assert_eq!(
746 sonic_rs::to_string(&SourcePredicate::between("age", Expr::param("lo"), 65i64))
747 .unwrap(),
748 r#"{"BetweenExpr":["age",{"Param":"lo"},{"Constant":{"I64":65}}]}"#
749 );
750 }
751
752 #[test]
753 fn source_predicate_json_round_trips() {
754 for sp in [
755 SourcePredicate::eq("username", "alice"),
756 SourcePredicate::eq("username", Expr::param("name")),
757 SourcePredicate::between("age", Expr::param("lo"), 65i64),
758 ] {
759 let json = sonic_rs::to_string(&sp).unwrap();
760 let back: SourcePredicate = sonic_rs::from_str(&json).unwrap();
761 assert_eq!(sp, back);
762 }
763 }
764
765 #[test]
768 fn query_ast_literal_vs_param_json() {
769 let literal = read_batch()
770 .var_as(
771 "user",
772 g().n_where(SourcePredicate::eq("username", "alice")),
773 )
774 .returning(["user"]);
775 let literal_json = sonic_rs::to_string(&literal).unwrap();
776 assert!(
777 literal_json.contains(r#"{"NWhere":{"Eq":["username",{"String":"alice"}]}}"#),
778 "literal NWhere step changed shape: {literal_json}"
779 );
780 assert!(!literal_json.contains("EqExpr"));
781
782 let param = read_batch()
783 .var_as(
784 "user",
785 g().n_where(SourcePredicate::eq("username", Expr::param("name"))),
786 )
787 .returning(["user"]);
788 let param_json = sonic_rs::to_string(¶m).unwrap();
789 assert!(
790 param_json.contains(r#"{"NWhere":{"EqExpr":["username",{"Param":"name"}]}}"#),
791 "param NWhere step missing EqExpr/Param: {param_json}"
792 );
793 }
794
795 #[test]
796 fn nested_dynamic_property_query_json() {
797 let metadata = PropertyValue::object(vec![
798 ("externalID", PropertyValue::from("some_id")),
799 ("score", PropertyValue::from(20i64)),
800 (
801 "tags",
802 PropertyValue::array(vec![
803 PropertyValue::from("alpha"),
804 PropertyValue::from(7i64),
805 ]),
806 ),
807 ]);
808
809 let write = write_batch()
810 .var_as(
811 "updated",
812 g().add_n(
813 "User",
814 vec![
815 ("name", PropertyInput::from("john")),
816 ("metadata", PropertyInput::from(metadata)),
817 ],
818 )
819 .set_property("metadata", PropertyInput::param("metadata"))
820 .value_map(Some(vec!["metadata.externalID"])),
821 )
822 .returning(["updated"]);
823 let write_json = sonic_rs::to_string(&write).unwrap();
824 assert!(
825 write_json
826 .contains(r#""metadata",{"Value":{"Object":{"externalID":{"String":"some_id"}"#),
827 "AddN nested object value changed shape: {write_json}"
828 );
829 assert!(
830 write_json.contains(r#""tags":{"Array":[{"String":"alpha"},{"I64":7}]}"#),
831 "AddN nested array value changed shape: {write_json}"
832 );
833 assert!(
834 write_json.contains(r#"{"SetProperty":["metadata",{"Expr":{"Param":"metadata"}}]}"#),
835 "SetProperty param changed shape: {write_json}"
836 );
837 assert!(
838 write_json.contains(r#"{"ValueMap":["metadata.externalID"]}"#),
839 "filtered ValueMap dotted path changed shape: {write_json}"
840 );
841
842 let read = read_batch()
843 .var_as(
844 "users",
845 g().n_where(SourcePredicate::and(vec![
846 SourcePredicate::eq("name", "john"),
847 SourcePredicate::eq("metadata.externalID", "some_id"),
848 ]))
849 .order_by("metadata.score", Order::Desc)
850 .project(vec![
851 Projection::property("metadata.externalID", "external_id"),
852 Projection::expr("score_copy", Expr::prop("metadata.score")),
853 ]),
854 )
855 .var_as(
856 "external_ids",
857 g().n_with_label("User").values(vec!["metadata.externalID"]),
858 )
859 .returning(["users", "external_ids"]);
860 let read_json = sonic_rs::to_string(&read).unwrap();
861 assert!(
862 read_json.contains(r#"{"Eq":["metadata.externalID",{"String":"some_id"}]}"#),
863 "dotted SourcePredicate changed shape: {read_json}"
864 );
865 assert!(
866 read_json.contains(r#"{"OrderBy":["metadata.score","Desc"]}"#),
867 "dotted OrderBy changed shape: {read_json}"
868 );
869 assert!(
870 read_json.contains(r#""source":"metadata.externalID","alias":"external_id""#),
871 "dotted property projection changed shape: {read_json}"
872 );
873 assert!(
874 read_json.contains(r#""expr":{"Property":"metadata.score"}"#),
875 "dotted expression projection changed shape: {read_json}"
876 );
877 assert!(
878 read_json.contains(r#"{"Values":["metadata.externalID"]}"#),
879 "dotted Values changed shape: {read_json}"
880 );
881 }
882}
883
884#[cfg(test)]
885mod client_tests {
886 use super::*;
891 use serde::Deserialize;
892
893 #[derive(Deserialize)]
894 struct Resp;
895
896 fn sample_request() -> DynamicQueryRequest {
897 DynamicQueryRequest::read(
898 read_batch()
899 .var_as(
900 "user",
901 g().n_where(SourcePredicate::eq("username", "alice")),
902 )
903 .returning(["user"]),
904 )
905 }
906
907 #[test]
910 fn new_defaults_to_localhost() {
911 let client = Client::new(None).unwrap();
912 assert_eq!(client.url.as_str(), "http://localhost:6969/v1/query");
913 assert!(client.api_key.is_none());
914 }
915
916 #[test]
917 fn new_parses_custom_url() {
918 let client = Client::new(Some("https://cluster.helix-db.com")).unwrap();
919 assert_eq!(client.url.as_str(), "https://cluster.helix-db.com/v1/query");
920 }
921
922 #[test]
923 fn new_rejects_invalid_url() {
924 let err = Client::new(Some("not a url")).unwrap_err();
925 assert!(matches!(err, HelixError::InvalidURL(_)));
926 }
927
928 #[test]
929 fn with_api_key_sets_and_clears() {
930 let client = Client::new(None).unwrap().with_api_key(Some("hx_secret"));
931 assert_eq!(client.api_key.as_deref(), Some("hx_secret"));
932
933 let cleared = client.with_api_key(None);
934 assert!(cleared.api_key.is_none());
935 }
936
937 #[test]
940 fn query_builder_starts_with_only_content_type() {
941 let client = Client::new(None).unwrap();
942 let builder = client.query::<Resp>();
943 assert_eq!(
944 builder.headers[0],
945 Some(("Content-Type", "application/json"))
946 );
947 assert!(builder.headers[1..].iter().all(Option::is_none));
948 }
949
950 #[test]
951 fn header_toggles_populate_slots() {
952 let client = Client::new(None).unwrap();
953 let builder = client
954 .query::<Resp>()
955 .writer_only()
956 .warm_only()
957 .should_await_durability(true);
958 assert_eq!(builder.headers[1], Some(("x-helix-require-writer", "true")));
959 assert_eq!(builder.headers[2], Some(("x-helix-warm", "true")));
960 assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "true")));
961 }
962
963 #[test]
964 fn should_await_durability_false_sends_false() {
965 let client = Client::new(None).unwrap();
966 let builder = client.query::<Resp>().should_await_durability(false);
967 assert_eq!(builder.headers[3], Some(("x-helix-await-durable", "false")));
968 }
969
970 #[test]
973 fn dynamic_query_sets_query_type() {
974 let client = Client::new(None).unwrap();
975 let request = client.query::<Resp>().dynamic(sample_request());
976 assert!(matches!(request.request.query_type, QueryType::Dynamic(_)));
977 }
978
979 #[test]
980 fn stored_query_sets_query_type() {
981 let client = Client::new(None).unwrap();
982 let request = client.query::<Resp>().stored("add_user".to_string());
983 assert!(
984 matches!(&request.request.query_type, QueryType::Stored(name) if name == "add_user")
985 );
986 }
987
988 #[derive(serde::Serialize)]
989 struct Payload {
990 name: String,
991 }
992
993 #[test]
994 fn body_serializes_payload() {
995 let client = Client::new(None).unwrap();
996 let payload = Payload {
997 name: "alice".to_string(),
998 };
999 let builder = client.query::<Resp>().body(&payload).unwrap();
1000 assert_eq!(builder.body, Some(sonic_rs::to_vec(&payload).unwrap()));
1001 }
1002
1003 #[derive(serde::Deserialize)]
1006 struct EmptyResp {}
1007
1008 async fn spawn_capture_server() -> (String, tokio::task::JoinHandle<String>) {
1011 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1012 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1013 let base = format!("http://{}", listener.local_addr().unwrap());
1014 let handle = tokio::spawn(async move {
1015 let (mut socket, _) = listener.accept().await.unwrap();
1016 let mut buf = [0u8; 4096];
1017 let n = socket.read(&mut buf).await.unwrap();
1018 let request_line = String::from_utf8_lossy(&buf[..n])
1019 .lines()
1020 .next()
1021 .unwrap()
1022 .to_string();
1023 let target = request_line.split_whitespace().nth(1).unwrap().to_string();
1025 let resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}";
1026 socket.write_all(resp.as_bytes()).await.unwrap();
1027 target
1028 });
1029 (base, handle)
1030 }
1031
1032 #[tokio::test]
1033 async fn dynamic_query_posts_to_v1_query() {
1034 let (base, handle) = spawn_capture_server().await;
1035 let client = Client::new(Some(&base)).unwrap();
1036 let _: EmptyResp = client
1037 .query()
1038 .dynamic(sample_request())
1039 .send()
1040 .await
1041 .unwrap();
1042 assert_eq!(handle.await.unwrap(), "/v1/query");
1043 }
1044
1045 #[tokio::test]
1046 async fn stored_query_posts_to_named_route() {
1047 let (base, handle) = spawn_capture_server().await;
1048 let client = Client::new(Some(&base)).unwrap();
1049 let _: EmptyResp = client
1050 .query()
1051 .stored("add_user".to_string())
1052 .send()
1053 .await
1054 .unwrap();
1055 assert_eq!(handle.await.unwrap(), "/v1/query/add_user");
1056 }
1057}