1use crate::auth::content::ContentApiKey;
23use crate::error::{GhostError, Result};
24use crate::models::author::Author;
25use crate::models::page::Page;
26use crate::models::pagination::Meta;
27use crate::models::post::Post;
28use crate::models::settings::Settings;
29use crate::models::tag::Tag;
30use reqwest::{header, Client};
31use serde::{Deserialize, Serialize};
32
33const GHOST_API_VERSION: &str = "v5.0";
34const CONTENT_API_PATH: &str = "/ghost/api/content";
35
36#[derive(Debug, Clone, Default)]
43pub struct BrowsePostsParams {
44 pub page: Option<u32>,
46 pub limit: Option<u32>,
48 pub include: Option<String>,
50 pub fields: Option<String>,
52 pub filter: Option<String>,
54 pub order: Option<String>,
56}
57
58impl BrowsePostsParams {
59 fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
60 let mut pairs = Vec::new();
61 if let Some(page) = self.page {
62 pairs.push(("page", page.to_string()));
63 }
64 if let Some(limit) = self.limit {
65 pairs.push(("limit", limit.to_string()));
66 }
67 if let Some(ref include) = self.include {
68 pairs.push(("include", include.clone()));
69 }
70 if let Some(ref fields) = self.fields {
71 pairs.push(("fields", fields.clone()));
72 }
73 if let Some(ref filter) = self.filter {
74 pairs.push(("filter", filter.clone()));
75 }
76 if let Some(ref order) = self.order {
77 pairs.push(("order", order.clone()));
78 }
79 pairs
80 }
81}
82
83pub type BrowsePagesParams = BrowsePostsParams;
87pub type BrowseTagsParams = BrowsePostsParams;
89pub type BrowseAuthorsParams = BrowsePostsParams;
91pub type BrowseTiersParams = BrowsePostsParams;
93
94#[derive(Debug, Deserialize)]
98pub struct PostsResponse {
99 pub posts: Vec<Post>,
101 pub meta: Meta,
103}
104
105#[derive(Debug, Deserialize)]
107pub struct PostResponse {
108 pub posts: Vec<Post>,
110}
111
112#[derive(Debug, Deserialize)]
114pub struct PagesResponse {
115 pub pages: Vec<Page>,
117 pub meta: Meta,
119}
120
121#[derive(Debug, Deserialize)]
123pub struct PageResponse {
124 pub pages: Vec<Page>,
126}
127
128#[derive(Debug, Deserialize)]
130pub struct TagsResponse {
131 pub tags: Vec<Tag>,
133 pub meta: Meta,
135}
136
137#[derive(Debug, Deserialize)]
139pub struct TagResponse {
140 pub tags: Vec<Tag>,
142}
143
144#[derive(Debug, Deserialize)]
146pub struct AuthorsResponse {
147 pub authors: Vec<Author>,
149 pub meta: Meta,
151}
152
153#[derive(Debug, Deserialize)]
155pub struct AuthorResponse {
156 pub authors: Vec<Author>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
162pub struct Tier {
163 pub id: String,
165 pub name: String,
167 pub slug: String,
169 #[serde(default)]
171 pub active: bool,
172 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
174 pub tier_type: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub welcome_page_url: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub description: Option<String>,
181 #[serde(default)]
183 pub benefits: Vec<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub monthly_price: Option<u64>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub yearly_price: Option<u64>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub currency: Option<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub created_at: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub updated_at: Option<String>,
199}
200
201#[derive(Debug, Deserialize)]
203pub struct TiersResponse {
204 pub tiers: Vec<Tier>,
206 pub meta: Meta,
208}
209
210#[derive(Debug, Deserialize)]
212pub struct SettingsResponse {
213 pub settings: Settings,
215}
216
217#[derive(Debug, Deserialize)]
220struct GhostApiErrors {
221 errors: Vec<GhostApiError>,
222}
223
224#[derive(Debug, Deserialize)]
225struct GhostApiError {
226 message: String,
227 #[serde(rename = "type")]
228 error_type: String,
229 context: Option<String>,
230}
231
232pub struct GhostContentClient {
263 base_url: String,
264 api_key: ContentApiKey,
265 http: Client,
266}
267
268impl GhostContentClient {
269 pub fn new(base_url: impl Into<String>, api_key: ContentApiKey) -> Result<Self> {
277 let mut base_url = base_url.into();
278 while base_url.ends_with('/') {
279 base_url.pop();
280 }
281
282 let mut default_headers = header::HeaderMap::new();
283 default_headers.insert(
284 "Accept-Version",
285 header::HeaderValue::from_static(GHOST_API_VERSION),
286 );
287
288 let http = Client::builder()
289 .default_headers(default_headers)
290 .build()
291 .map_err(GhostError::Http)?;
292
293 Ok(Self {
294 base_url,
295 api_key,
296 http,
297 })
298 }
299
300 pub async fn browse_posts(&self, params: BrowsePostsParams) -> Result<PostsResponse> {
309 let url = format!("{}{}/posts/", self.base_url, CONTENT_API_PATH);
310 let mut query = self.base_query();
311 query.extend(params.to_query_pairs());
312 let response = self.http.get(&url).query(&query).send().await?;
313 self.parse_response::<PostsResponse>(response).await
314 }
315
316 pub async fn read_post_by_id(&self, id: &str, include: Option<&str>) -> Result<Post> {
325 let url = format!("{}{}/posts/{}/", self.base_url, CONTENT_API_PATH, id);
326 self.read_single_item::<PostResponse, Post>(&url, include, |r| r.posts)
327 .await
328 .and_then(|opt| {
329 opt.ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
330 })
331 }
332
333 pub async fn read_post_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Post> {
342 let url = format!("{}{}/posts/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
343 self.read_single_item::<PostResponse, Post>(&url, include, |r| r.posts)
344 .await
345 .and_then(|opt| {
346 opt.ok_or_else(|| GhostError::api("Post not found", "NotFoundError", None))
347 })
348 }
349
350 pub async fn browse_pages(&self, params: BrowsePagesParams) -> Result<PagesResponse> {
359 let url = format!("{}{}/pages/", self.base_url, CONTENT_API_PATH);
360 let mut query = self.base_query();
361 query.extend(params.to_query_pairs());
362 let response = self.http.get(&url).query(&query).send().await?;
363 self.parse_response::<PagesResponse>(response).await
364 }
365
366 pub async fn read_page_by_id(&self, id: &str, include: Option<&str>) -> Result<Page> {
375 let url = format!("{}{}/pages/{}/", self.base_url, CONTENT_API_PATH, id);
376 self.read_single_item::<PageResponse, Page>(&url, include, |r| r.pages)
377 .await
378 .and_then(|opt| {
379 opt.ok_or_else(|| GhostError::api("Page not found", "NotFoundError", None))
380 })
381 }
382
383 pub async fn read_page_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Page> {
392 let url = format!("{}{}/pages/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
393 self.read_single_item::<PageResponse, Page>(&url, include, |r| r.pages)
394 .await
395 .and_then(|opt| {
396 opt.ok_or_else(|| GhostError::api("Page not found", "NotFoundError", None))
397 })
398 }
399
400 pub async fn browse_tags(&self, params: BrowseTagsParams) -> Result<TagsResponse> {
409 let url = format!("{}{}/tags/", self.base_url, CONTENT_API_PATH);
410 let mut query = self.base_query();
411 query.extend(params.to_query_pairs());
412 let response = self.http.get(&url).query(&query).send().await?;
413 self.parse_response::<TagsResponse>(response).await
414 }
415
416 pub async fn read_tag_by_id(&self, id: &str, include: Option<&str>) -> Result<Tag> {
425 let url = format!("{}{}/tags/{}/", self.base_url, CONTENT_API_PATH, id);
426 self.read_single_item::<TagResponse, Tag>(&url, include, |r| r.tags)
427 .await
428 .and_then(|opt| {
429 opt.ok_or_else(|| GhostError::api("Tag not found", "NotFoundError", None))
430 })
431 }
432
433 pub async fn read_tag_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Tag> {
442 let url = format!("{}{}/tags/slug/{}/", self.base_url, CONTENT_API_PATH, slug);
443 self.read_single_item::<TagResponse, Tag>(&url, include, |r| r.tags)
444 .await
445 .and_then(|opt| {
446 opt.ok_or_else(|| GhostError::api("Tag not found", "NotFoundError", None))
447 })
448 }
449
450 pub async fn browse_authors(&self, params: BrowseAuthorsParams) -> Result<AuthorsResponse> {
459 let url = format!("{}{}/authors/", self.base_url, CONTENT_API_PATH);
460 let mut query = self.base_query();
461 query.extend(params.to_query_pairs());
462 let response = self.http.get(&url).query(&query).send().await?;
463 self.parse_response::<AuthorsResponse>(response).await
464 }
465
466 pub async fn read_author_by_id(&self, id: &str, include: Option<&str>) -> Result<Author> {
475 let url = format!("{}{}/authors/{}/", self.base_url, CONTENT_API_PATH, id);
476 self.read_single_item::<AuthorResponse, Author>(&url, include, |r| r.authors)
477 .await
478 .and_then(|opt| {
479 opt.ok_or_else(|| GhostError::api("Author not found", "NotFoundError", None))
480 })
481 }
482
483 pub async fn read_author_by_slug(&self, slug: &str, include: Option<&str>) -> Result<Author> {
492 let url = format!(
493 "{}{}/authors/slug/{}/",
494 self.base_url, CONTENT_API_PATH, slug
495 );
496 self.read_single_item::<AuthorResponse, Author>(&url, include, |r| r.authors)
497 .await
498 .and_then(|opt| {
499 opt.ok_or_else(|| GhostError::api("Author not found", "NotFoundError", None))
500 })
501 }
502
503 pub async fn browse_tiers(&self, params: BrowseTiersParams) -> Result<TiersResponse> {
512 let url = format!("{}{}/tiers/", self.base_url, CONTENT_API_PATH);
513 let mut query = self.base_query();
514 query.extend(params.to_query_pairs());
515 let response = self.http.get(&url).query(&query).send().await?;
516 self.parse_response::<TiersResponse>(response).await
517 }
518
519 pub async fn get_settings(&self) -> Result<Settings> {
528 let url = format!("{}{}/settings/", self.base_url, CONTENT_API_PATH);
529 let query = self.base_query();
530 let response = self.http.get(&url).query(&query).send().await?;
531 self.parse_response::<SettingsResponse>(response)
532 .await
533 .map(|r| r.settings)
534 }
535
536 fn base_query(&self) -> Vec<(&'static str, String)> {
539 vec![("key", self.api_key.as_str().to_string())]
540 }
541
542 async fn read_single_item<E, T>(
543 &self,
544 url: &str,
545 include: Option<&str>,
546 extract: impl FnOnce(E) -> Vec<T>,
547 ) -> Result<Option<T>>
548 where
549 E: for<'de> Deserialize<'de>,
550 {
551 let mut query = self.base_query();
552 if let Some(inc) = include {
553 query.push(("include", inc.to_string()));
554 }
555 let response = self.http.get(url).query(&query).send().await?;
556 let envelope = self.parse_response::<E>(response).await?;
557 Ok(extract(envelope).into_iter().next())
558 }
559
560 async fn parse_response<T: for<'de> Deserialize<'de>>(
561 &self,
562 response: reqwest::Response,
563 ) -> Result<T> {
564 let status = response.status();
565 if status.is_success() {
566 Ok(response.json::<T>().await?)
567 } else {
568 let api_errors: GhostApiErrors = response.json().await?;
569 let first = api_errors
570 .errors
571 .into_iter()
572 .next()
573 .unwrap_or(GhostApiError {
574 message: "Unknown API error".to_string(),
575 error_type: "UnknownError".to_string(),
576 context: None,
577 });
578 Err(GhostError::api(
579 first.message,
580 first.error_type,
581 first.context,
582 ))
583 }
584 }
585}
586
587#[cfg(test)]
590mod tests {
591 use super::*;
592
593 const VALID_KEY: &str = "22444f78447824223cefc48062";
594
595 fn make_key() -> ContentApiKey {
596 ContentApiKey::new(VALID_KEY).unwrap()
597 }
598
599 fn make_client() -> GhostContentClient {
600 GhostContentClient::new("https://demo.ghost.io", make_key()).unwrap()
601 }
602
603 #[test]
606 fn test_client_creation() {
607 assert!(GhostContentClient::new("https://demo.ghost.io", make_key()).is_ok());
608 }
609
610 #[test]
611 fn test_client_strips_trailing_slash() {
612 let client = GhostContentClient::new("https://demo.ghost.io/", make_key()).unwrap();
613 assert_eq!(client.base_url, "https://demo.ghost.io");
614 }
615
616 #[test]
617 fn test_client_strips_multiple_trailing_slashes() {
618 let client = GhostContentClient::new("https://demo.ghost.io///", make_key()).unwrap();
619 assert_eq!(client.base_url, "https://demo.ghost.io");
620 }
621
622 #[test]
625 fn test_base_query_contains_key() {
626 let client = make_client();
627 let query = client.base_query();
628 assert_eq!(query.len(), 1);
629 assert_eq!(query[0], ("key", VALID_KEY.to_string()));
630 }
631
632 #[test]
635 fn test_browse_params_default_empty() {
636 let params = BrowsePostsParams::default();
637 assert!(params.to_query_pairs().is_empty());
638 }
639
640 #[test]
641 fn test_browse_params_all_fields() {
642 let params = BrowsePostsParams {
643 page: Some(2),
644 limit: Some(10),
645 include: Some("authors,tags".to_string()),
646 fields: Some("id,title".to_string()),
647 filter: Some("featured:true".to_string()),
648 order: Some("published_at DESC".to_string()),
649 };
650 let pairs = params.to_query_pairs();
651 assert_eq!(pairs.len(), 6);
652
653 let map: std::collections::HashMap<_, _> = pairs.into_iter().collect();
654 assert_eq!(map["page"], "2");
655 assert_eq!(map["limit"], "10");
656 assert_eq!(map["include"], "authors,tags");
657 assert_eq!(map["fields"], "id,title");
658 assert_eq!(map["filter"], "featured:true");
659 assert_eq!(map["order"], "published_at DESC");
660 }
661
662 #[test]
663 fn test_browse_params_partial() {
664 let params = BrowsePostsParams {
665 page: Some(1),
666 include: Some("authors".to_string()),
667 ..Default::default()
668 };
669 assert_eq!(params.to_query_pairs().len(), 2);
670 }
671
672 #[test]
675 fn test_tier_deserialization() {
676 let json = serde_json::json!({
677 "id": "tier1",
678 "name": "Free",
679 "slug": "free",
680 "active": true,
681 "type": "free"
682 });
683 let tier: Tier = serde_json::from_value(json).unwrap();
684 assert_eq!(tier.id, "tier1");
685 assert!(tier.active);
686 assert_eq!(tier.tier_type.as_deref(), Some("free"));
687 }
688
689 #[test]
690 fn test_tier_default() {
691 let tier = Tier::default();
692 assert!(tier.id.is_empty());
693 assert!(!tier.active);
694 assert!(tier.benefits.is_empty());
695 }
696
697 #[test]
698 fn test_tier_with_pricing() {
699 let json = serde_json::json!({
700 "id": "tier2",
701 "name": "Supporter",
702 "slug": "supporter",
703 "active": true,
704 "type": "paid",
705 "monthly_price": 500,
706 "yearly_price": 5000,
707 "currency": "usd",
708 "benefits": ["No ads", "Newsletter"]
709 });
710 let tier: Tier = serde_json::from_value(json).unwrap();
711 assert_eq!(tier.monthly_price, Some(500));
712 assert_eq!(tier.yearly_price, Some(5000));
713 assert_eq!(tier.currency.as_deref(), Some("usd"));
714 assert_eq!(tier.benefits.len(), 2);
715 }
716
717 #[test]
718 fn test_tier_clone_eq() {
719 let tier = Tier {
720 id: "1".to_string(),
721 name: "Free".to_string(),
722 slug: "free".to_string(),
723 ..Default::default()
724 };
725 assert_eq!(tier, tier.clone());
726 }
727
728 #[test]
731 fn test_settings_response_deserialization() {
732 let json = serde_json::json!({
733 "settings": {
734 "title": "Test Blog",
735 "lang": "en"
736 }
737 });
738 let resp: SettingsResponse = serde_json::from_value(json).unwrap();
739 assert_eq!(resp.settings.title.as_deref(), Some("Test Blog"));
740 }
741
742 #[test]
743 fn test_pages_response_deserialization() {
744 let json = serde_json::json!({
745 "pages": [
746 { "id": "1", "title": "About", "slug": "about" }
747 ],
748 "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
749 });
750 let resp: PagesResponse = serde_json::from_value(json).unwrap();
751 assert_eq!(resp.pages.len(), 1);
752 assert_eq!(resp.pages[0].slug, "about");
753 }
754
755 #[test]
756 fn test_tags_response_deserialization() {
757 let json = serde_json::json!({
758 "tags": [
759 { "id": "t1", "name": "Rust", "slug": "rust" }
760 ],
761 "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
762 });
763 let resp: TagsResponse = serde_json::from_value(json).unwrap();
764 assert_eq!(resp.tags.len(), 1);
765 assert_eq!(resp.tags[0].name, "Rust");
766 }
767
768 #[test]
769 fn test_authors_response_deserialization() {
770 let json = serde_json::json!({
771 "authors": [
772 { "id": "a1", "name": "Jane", "slug": "jane" }
773 ],
774 "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
775 });
776 let resp: AuthorsResponse = serde_json::from_value(json).unwrap();
777 assert_eq!(resp.authors.len(), 1);
778 assert_eq!(resp.authors[0].name, "Jane");
779 }
780
781 #[test]
782 fn test_tiers_response_deserialization() {
783 let json = serde_json::json!({
784 "tiers": [
785 { "id": "t1", "name": "Free", "slug": "free", "active": true }
786 ],
787 "meta": { "pagination": { "page": 1, "limit": 15, "pages": 1, "total": 1 } }
788 });
789 let resp: TiersResponse = serde_json::from_value(json).unwrap();
790 assert_eq!(resp.tiers.len(), 1);
791 assert!(resp.tiers[0].active);
792 }
793}
794
795#[cfg(test)]
798#[cfg(feature = "integration-tests")]
799mod integration_tests {
800 use super::*;
801
802 const DEMO_URL: &str = "https://demo.ghost.io";
803 const DEMO_KEY: &str = "22444f78447824223cefc48062";
804
805 fn make_client() -> GhostContentClient {
806 let key = ContentApiKey::new(DEMO_KEY).unwrap();
807 GhostContentClient::new(DEMO_URL, key).unwrap()
808 }
809
810 #[tokio::test]
811 async fn test_browse_posts_integration() {
812 let client = make_client();
813 let result = client.browse_posts(BrowsePostsParams::default()).await;
814 assert!(result.is_ok(), "browse_posts failed: {:?}", result);
815 let response = result.unwrap();
816 assert!(!response.posts.is_empty());
817 assert!(response.meta.pagination.page >= 1);
818 }
819
820 #[tokio::test]
821 async fn test_browse_posts_with_limit() {
822 let client = make_client();
823 let params = BrowsePostsParams {
824 limit: Some(2),
825 ..Default::default()
826 };
827 let result = client.browse_posts(params).await.unwrap();
828 assert!(result.posts.len() <= 2);
829 }
830
831 #[tokio::test]
832 async fn test_read_post_by_slug_integration() {
833 let client = make_client();
834 let browse = client
835 .browse_posts(BrowsePostsParams {
836 limit: Some(1),
837 ..Default::default()
838 })
839 .await
840 .unwrap();
841 let slug = &browse.posts[0].slug;
842 let post = client.read_post_by_slug(slug, None).await.unwrap();
843 assert_eq!(&post.slug, slug);
844 }
845
846 #[tokio::test]
847 async fn test_read_post_by_id_integration() {
848 let client = make_client();
849 let browse = client
850 .browse_posts(BrowsePostsParams {
851 limit: Some(1),
852 ..Default::default()
853 })
854 .await
855 .unwrap();
856 let id = &browse.posts[0].id;
857 let post = client.read_post_by_id(id, None).await.unwrap();
858 assert_eq!(&post.id, id);
859 }
860
861 #[tokio::test]
862 async fn test_read_post_not_found() {
863 let client = make_client();
864 let err = client
865 .read_post_by_id("000000000000000000000000", None)
866 .await
867 .unwrap_err();
868 assert!(err.is_api_error());
869 }
870
871 #[tokio::test]
872 async fn test_browse_pages_integration() {
873 let client = make_client();
874 let result = client.browse_pages(BrowsePagesParams::default()).await;
875 assert!(result.is_ok(), "browse_pages failed: {:?}", result);
876 }
877
878 #[tokio::test]
879 async fn test_browse_tags_integration() {
880 let client = make_client();
881 let result = client.browse_tags(BrowseTagsParams::default()).await;
882 assert!(result.is_ok(), "browse_tags failed: {:?}", result);
883 assert!(!result.unwrap().tags.is_empty());
884 }
885
886 #[tokio::test]
887 async fn test_read_tag_by_slug_integration() {
888 let client = make_client();
889 let tags = client
890 .browse_tags(BrowseTagsParams {
891 limit: Some(1),
892 ..Default::default()
893 })
894 .await
895 .unwrap();
896 let slug = &tags.tags[0].slug;
897 let tag = client.read_tag_by_slug(slug, None).await.unwrap();
898 assert_eq!(&tag.slug, slug);
899 }
900
901 #[tokio::test]
902 async fn test_browse_authors_integration() {
903 let client = make_client();
904 let result = client.browse_authors(BrowseAuthorsParams::default()).await;
905 assert!(result.is_ok(), "browse_authors failed: {:?}", result);
906 assert!(!result.unwrap().authors.is_empty());
907 }
908
909 #[tokio::test]
910 async fn test_get_settings_integration() {
911 let client = make_client();
912 let result = client.get_settings().await;
913 assert!(result.is_ok(), "get_settings failed: {:?}", result);
914 let settings = result.unwrap();
915 assert!(settings.title.is_some());
916 }
917}