Skip to main content

ploidy_codegen_rust/
client.rs

1use ploidy_core::codegen::IntoCode;
2use proc_macro2::TokenStream;
3use quote::{ToTokens, TokenStreamExt, quote};
4
5use super::{
6    cfg::CfgFeature,
7    graph::CodegenGraph,
8    naming::{CodegenIdentUsage, ResourceGroup},
9};
10
11/// Generates the `client/mod.rs` source file.
12#[derive(Debug)]
13pub struct CodegenClientModule<'a> {
14    graph: &'a CodegenGraph<'a>,
15    resources: &'a [ResourceGroup<'a>],
16}
17
18impl<'a> CodegenClientModule<'a> {
19    pub fn new(graph: &'a CodegenGraph<'a>, resources: &'a [ResourceGroup<'a>]) -> Self {
20        Self { graph, resources }
21    }
22}
23
24impl ToTokens for CodegenClientModule<'_> {
25    fn to_tokens(&self, tokens: &mut TokenStream) {
26        let client_doc = self.graph.info().label().map(|label| {
27            let doc = match label.version {
28                Some(version) => format!("API client for {} (version {version})", label.title),
29                None => format!("API client for {}", label.title),
30            };
31            quote! { #[doc = #doc] }
32        });
33
34        let mods = ResourceModules(self.resources);
35
36        tokens.append_all(quote! {
37            #client_doc
38            #[derive(Clone, Debug)]
39            pub struct Client {
40                client: ::ploidy_util::reqwest::Client,
41                headers: ::ploidy_util::http::HeaderMap,
42                base_url: ::ploidy_util::url::Url,
43            }
44
45            impl Client {
46                /// Creates a new client.
47                pub fn new(base_url: impl AsRef<str>) -> Result<Self, crate::error::Error> {
48                    Ok(Self::with_reqwest_client(
49                        ::ploidy_util::reqwest::Client::new(),
50                        base_url.as_ref().parse()?,
51                    ))
52                }
53
54                pub fn with_reqwest_client(
55                    client: crate::util::reqwest::Client,
56                    base_url: crate::util::url::Url,
57                ) -> Self {
58                    Self {
59                        client,
60                        headers: ::ploidy_util::http::HeaderMap::new(),
61                        base_url,
62                    }
63                }
64
65                /// Adds a header to each request.
66                pub fn with_header<K, V>(mut self, name: K, value: V) -> Result<Self, crate::error::Error>
67                where
68                    K: TryInto<crate::util::http::HeaderName>,
69                    V: TryInto<crate::util::http::HeaderValue>,
70                    K::Error: Into<crate::util::http::Error>,
71                    V::Error: Into<crate::util::http::Error>,
72                {
73                    let name = name
74                        .try_into()
75                        .map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
76                    let value = value
77                        .try_into()
78                        .map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
79                    self.headers.insert(name, value);
80                    Ok(Self {
81                        client: self.client,
82                        headers: self.headers,
83                        base_url: self.base_url,
84                    })
85                }
86
87                /// Adds a sensitive header to each request, like a password or a bearer token.
88                /// Sensitive headers won't appear in `Debug` output, and may be treated specially
89                /// by the underlying HTTP stack.
90                ///
91                /// # Example
92                ///
93                /// ```rust,ignore
94                /// use reqwest::header::AUTHORIZATION;
95                ///
96                /// let client = Client::new("https://api.example.com")?
97                ///     .with_sensitive_header(AUTHORIZATION, "Bearer decafbadcafed00d")?;
98                /// ```
99                pub fn with_sensitive_header<K, V>(self, name: K, value: V) -> Result<Self, crate::error::Error>
100                where
101                    K: TryInto<crate::util::http::HeaderName>,
102                    V: TryInto<crate::util::http::HeaderValue>,
103                    K::Error: Into<crate::util::http::Error>,
104                    V::Error: Into<crate::util::http::Error>,
105                {
106                    let name = name
107                        .try_into()
108                        .map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
109                    let mut value: ::ploidy_util::http::HeaderValue = value
110                        .try_into()
111                        .map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
112                    value.set_sensitive(true);
113                    self.with_header(name, value)
114                }
115
116                pub fn with_user_agent<V>(self, value: V) -> Result<Self, crate::error::Error>
117                where
118                    V: TryInto<crate::util::http::HeaderValue>,
119                    V::Error: Into<crate::util::http::Error>,
120                {
121                    self.with_header(::ploidy_util::http::header::USER_AGENT, value)
122                }
123
124                /// Returns a raw [`RequestBuilder`].
125                ///
126                /// Constructs the request URL by appending `path_and_query`
127                /// to the base URL's path and query, respectively. For example,
128                /// given a base URL of `https://api.example.com/v1` and a
129                /// `path_and_query` of `/pets/list?limit=10`, the request URL is
130                /// `https://api.example.com/v1/pets/list?limit=10`.
131                ///
132                /// The request includes the client's default headers.
133                ///
134                /// Use this for requests that the typed client methods
135                /// don't support.
136                ///
137                /// [`RequestBuilder`]: crate::util::reqwest::RequestBuilder
138                pub fn request(
139                    &self,
140                    method: crate::util::reqwest::Method,
141                    path_and_query: &str,
142                ) -> Result<crate::util::reqwest::RequestBuilder, crate::error::Error> {
143                    let parts: ::ploidy_util::http::uri::PathAndQuery = path_and_query.parse()?;
144                    let mut url = self.base_url.clone();
145                    let _ = url.path_segments_mut().map(|mut segments| {
146                        let path = parts.path();
147                        if path != "/" {
148                            let path = path
149                                .strip_prefix('/') // Drop leading `/` from new path.
150                                .unwrap_or(path);
151                            segments
152                                .pop_if_empty() // Drop trailing `/` from the base path.
153                                .extend(path.split('/'));
154                        }
155                    });
156                    if let Some(query) = parts.query() {
157                        url.query_pairs_mut()
158                            .extend_pairs(::ploidy_util::url::form_urlencoded::parse(query.as_bytes()));
159                    }
160                    Ok(self.client
161                        .request(method, url)
162                        .headers(self.headers.clone()))
163                }
164            }
165
166            #mods
167        });
168    }
169}
170
171impl IntoCode for CodegenClientModule<'_> {
172    type Code = (&'static str, TokenStream);
173
174    fn into_code(self) -> Self::Code {
175        ("src/client/mod.rs", self.into_token_stream())
176    }
177}
178
179#[derive(Debug)]
180struct ResourceModules<'a>(&'a [ResourceGroup<'a>]);
181
182impl ToTokens for ResourceModules<'_> {
183    fn to_tokens(&self, tokens: &mut TokenStream) {
184        tokens.append_all(self.0.iter().map(|ident| match ident {
185            ResourceGroup::Named(name) => {
186                let cfg = CfgFeature::Single(name);
187                let mod_name = CodegenIdentUsage::Module(name);
188                quote! {
189                    #cfg
190                    pub mod #mod_name;
191                }
192            }
193            ResourceGroup::Default => quote!(
194                pub mod default;
195            ),
196        }));
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    use ploidy_core::arena::Arena;
205    use pretty_assertions::assert_eq;
206    use syn::parse_quote;
207
208    use crate::naming::UniqueIdents;
209
210    #[test]
211    fn test_resource_modules_gates_named_resources_and_keeps_default_ungated() {
212        let arena = Arena::new();
213        let mut scope = UniqueIdents::new(&arena);
214        let resources = [
215            ResourceGroup::Default,
216            ResourceGroup::Named(scope.ident("customer_profiles")),
217        ];
218        let modules = ResourceModules(&resources);
219
220        let actual: syn::File = parse_quote!(#modules);
221        let expected: syn::File = parse_quote! {
222            pub mod default;
223
224            #[cfg(feature = "customer-profiles")]
225            pub mod customer_profiles;
226        };
227        assert_eq!(actual, expected);
228    }
229}