crud_api/
lib.rs

1//! # Crud Api
2//! 
3//! This crate provides a framework to generate an executable to manipulate your HTTP API from CLI.
4//! 
5//! The apps using this lib can replace your _curl_ queries when you need to access to your favorite API.
6//! 
7//! ## Features
8//! 
9//! API:
10//! - data are encoded in JSON. It don't support XML, grpc, ...
11//! - output can be formated on json, yaml, toml, csv or tsv
12//! - output stream on stdout or in a file
13//! 
14//! 
15//! ## Tutorial
16//! 
17//! Let's create an CLI for [jsonplaceholder](http://jsonplaceholder.typicode.com/) API.
18//! For the impatients, the whole code of this example can be found in [`examples/jsonplaceholder_api.rs`](./examples/jsonplaceholder_api.rs "jsonplaceholder_api.rs")
19//! 
20//! First add these dependencies to `Cargo.toml`:
21//! ```toml
22//! [dependencies]
23//! log = "0.4"
24//! pretty_env_logger = "0.5"
25//! clap = "4.3"
26//! crud-api = {version = "0.1", path="../crud/crud-api", default-features=false, features=["toml","json","yaml"]}
27//! crud-auth = {version = "0.1", path="../crud/crud-auth"}
28//! crud-auth-bearer = {version = "0.1", path="../crud/crud-auth-bearer"}
29//! hyper = { version = "0.14", features = ["client","http1"] }
30//! hyper-tls = "0.5"
31//! miette = { version = "5.9", features = ["fancy"] }
32//! tokio = { version = "1", features = ["full"] }
33//! serde = { version = "1.0", features = ["derive"] }
34//! # To force static openssl
35//! openssl = { version = "0.10", features = ["vendored"] }
36//! ```
37//! 
38//! Now, create a minimal runner stucture and a `main` function.
39//! `ApiRun` on `JSONPlaceHolder` derives all the CLI.
40//! ```rust
41//! use crud_api::ApiRun;
42//! use crud_auth::CrudAuth;
43//! use crud_auth_no_auth::Auth;
44//! use miette::{IntoDiagnostic, Result};
45//! 
46//! #[derive(ApiRun)]
47//! struct JSONPlaceHolder;
48//! 
49//! #[tokio::main]
50//! async fn main() -> Result<()> {
51//!   JSONPlaceHolder::run().await
52//! }
53//! ```
54//! [`crud_api_endpoint::ApiRun`] accepts some parameters. They are documented in `crud_api_endoint` crate.
55//! Let's customize our CLI with a `base_url` for our API, a `name` used in the documentation and the settings. `qualifier` and `organisation` is used to compute the settings location and `env_prefix` is the prefix of the environment variables
56//! ```rust
57//! # use crud_api::ApiRun;
58//! # use crud_auth::CrudAuth;
59//! # use crud_auth_no_auth::Auth;
60//! # use miette::{IntoDiagnostic, Result};
61//! #[derive(ApiRun)]
62//! #[api(infos(
63//!   base_url = "http://jsonplaceholder.typicode.com",
64//!   name = "jsonplaceholder",
65//!   qualifier = "com",
66//!   organisation = "typicode",
67//!   env_prefix = "JSONPLACEHOLDER"
68//! ))]
69//! struct JSONPlaceHolder;
70//! # #[tokio::main]
71//! # async fn main() -> Result<()> {
72//! #   JSONPlaceHolder::run().await
73//! # }
74//! ```
75//! Before creating the first endpoint we need to describe its output structure.
76//! ```rust
77//! use serde::{Deserialize, Serialize};
78//! #[derive(Debug, Default, Deserialize, Serialize)]
79//! struct Post {
80//!   id: u32,
81//!   #[serde(rename = "userId")]
82//!   user_id: u32,
83//!   title: String,
84//!   body: String,
85//! }
86//! ```
87//! 
88//! Now, we can declare the endpoint.
89//! The minimal parameters are:
90//! - `route`, the target api route.
91//! - `cli_route`, the route transcipted as cli arguments. Each slash separate a subcommand.
92//! The other parameters can found in [`crud_api_endpoint::Api`] and [`crud_api_endpoint::Enpoint`] structs documentation.
93//! 
94//! ```rust
95//! # use serde::{Deserialize, Serialize};
96//! use crud_api::Api;
97//! #[derive(Api, Debug, Default, Deserialize, Serialize)]
98//! #[api(
99//!   endpoint(
100//!     route = "/posts",
101//!     cli_route = "/post",
102//!     multiple_results,
103//!   ))]
104//! struct Post {
105//!   id: u32,
106//!   #[serde(rename = "userId")]
107//!   user_id: u32,
108//!   title: String,
109//!   body: String,
110//! }
111//! ```
112//! We can create more complex enpoint. Let's create an edit route.
113//! 
114//! - The `route` parameter takes a post's `id` argument. This argument should be present in the `cli_route`.
115//! - the HTTP method is set with the `method` parameter.
116//! - Some help can be provided via the parameters `cli_help` and `cli_long_help`.
117//! - the payload is described by the struct declared with the `payload_struct`. The query parameter can be add with the `query_struct` parameter.
118//! 
119//! In this step, the payload structure is `PostCreate` (the same structure is used for both creation and update). `PostCreate` derives `ApiInput`. All `PostCreate` fields parameters are describe in the [`crud_api_endpoint::ApiInputConfig`] structs.
120//! 
121//! 
122//! ```rust
123//! # use serde::{Deserialize, Serialize};
124//! use crud_api::{Api, ApiInput};
125//! #[derive(Api, Debug, Default, Deserialize, Serialize)]
126//! #[api(
127//!   endpoint(
128//!     route = "/posts",
129//!     cli_route = "/post",
130//!     multiple_results,
131//!   ),
132//!   endpoint(
133//!     route = "/posts/{id}",
134//!     method = "PUT",
135//!     payload_struct = "PostCreate",
136//!     cli_route = "/post/{id}/replace",
137//!     cli_help = "Update a Posts (replace the whole post)"
138//!   )
139//! )]
140//! struct Post {
141//!   id: u32,
142//!   #[serde(rename = "userId")]
143//!   user_id: u32,
144//!   title: String,
145//!   body: String,
146//! }
147//! 
148//! #[derive(Debug, ApiInput, Default, Serialize, Deserialize)]
149//! #[allow(dead_code, non_snake_case)]
150//! struct PostCreate {
151//!   #[api(long = "user-id")]
152//!   userId: u32,
153//!   #[api(no_short, help = "Title of the post")]
154//!   title: String,
155//!   #[api(no_short)]
156//!   body: String,
157//! }
158//! ```
159//! 
160//! ## Output Customization
161//! 
162//! ### Tables
163//! 
164//! Results arrays are formatted using the crate [`crud-tidy-viewer`](crud_tidy_viewer).
165//! The available table column options are:
166//! - [`table_skip`](../crud_api_endpoint/struct.ApiField.html#structfield.table_skip): don't display this field in the table.
167//! - [`table_format`](../crud_api_endpoint/struct.ApiField.html#structfield.table_format): format this field in table.
168//!   - date formatter: `date(format = "%Y-%m-%d %H:%M:%S")`
169//! 
170//! ### Pretty Structures
171//! 
172//! The crate [`crud-pretty-struct`](crud_pretty_struct) can format a single (json) struct.
173
174use async_trait::async_trait;
175use clap::{ArgMatches, Command, Id};
176pub use crud_api_derive::*;
177use crud_pretty_struct::PrettyPrint;
178#[cfg(any(feature = "json", feature = "toml", feature = "yaml", feature = "csv"))]
179use crud_tidy_viewer::{display_table, TableConfig};
180use formats::OutputFormat;
181#[doc(hidden)]
182pub use formats::{
183  clap_match_input_from_file, clap_match_output_format, clap_match_template, clap_output_format_decl,
184};
185use miette::{IntoDiagnostic, Result};
186use serde::{de::DeserializeOwned, Deserialize, Serialize};
187use std::{
188  fmt::Debug,
189  marker::{PhantomData, Sized},
190};
191
192extern crate crud_api_derive;
193#[doc(hidden)]
194pub mod cli;
195#[doc(hidden)]
196pub mod completions;
197#[doc(hidden)]
198pub mod error;
199mod formats;
200#[doc(hidden)]
201pub mod http;
202#[doc(hidden)]
203pub mod settings;
204
205#[doc(hidden)]
206pub struct ApiInputOptions {
207  pub conflicts_with_all: Vec<Id>,
208}
209
210#[doc(hidden)]
211pub trait ApiInput {
212  /// Generate the clap command declatations.
213  fn clap(app: Command, options: Option<ApiInputOptions>) -> Command;
214  fn from_clap_matches(matches: &ArgMatches) -> Result<Self>
215  where
216    Self: Sized;
217}
218
219#[doc(hidden)]
220#[async_trait]
221pub trait Query {
222  async fn query<P, T, R, Q>(
223    &self,
224    payload: Option<P>,
225    argument: Option<Q>,
226    t: Option<PhantomData<T>>,
227  ) -> Result<R>
228  where
229    P: Send + Serialize + Debug,
230    T: TryInto<R, Error = String> + DeserializeOwned + Send,
231    R: Send + DeserializeOwned + Debug + Default,
232    Q: Send + Serialize + Debug;
233  async fn stream<P, Q>(
234    &self,
235    payload: Option<P>,
236    argument: Option<Q>,
237    filename: Option<String>,
238  ) -> Result<()>
239  where
240    P: Send + Serialize + Debug,
241    Q: Send + Serialize + Debug;
242}
243
244#[doc(hidden)]
245pub trait Api {
246  fn to_table_header(&self) -> Vec<String>;
247  fn to_table(&self) -> Result<Vec<String>>;
248  fn to_output(&self) -> Result<String>;
249
250  #[cfg(any(feature = "json", feature = "toml", feature = "yaml", feature = "csv"))]
251  fn output(&self, format: Option<OutputFormat>) -> Result<()>
252  where
253    Self: Serialize + Debug,
254  {
255    let out = match format {
256      Some(format) => match format {
257        #[cfg(feature = "json")]
258        OutputFormat::Json => Some(serde_json::to_string_pretty(self).into_diagnostic()?),
259        #[cfg(feature = "toml")]
260        OutputFormat::Toml => Some(toml::to_string(self).into_diagnostic()?),
261        #[cfg(feature = "yaml")]
262        OutputFormat::Yaml => Some(serde_yaml::to_string(self).into_diagnostic()?),
263        #[cfg(feature = "csv")]
264        OutputFormat::Csv => {
265          let mut wtr = csv::Writer::from_writer(std::io::stdout());
266          wtr.serialize(self).into_diagnostic()?;
267          wtr.flush().into_diagnostic()?;
268          None
269        }
270        #[cfg(feature = "csv")]
271        OutputFormat::Tsv => {
272          let mut wtr = csv::WriterBuilder::new()
273            .delimiter(b'\t')
274            .quote_style(csv::QuoteStyle::NonNumeric)
275            .from_writer(std::io::stdout());
276          wtr.serialize(self).into_diagnostic()?;
277          wtr.flush().into_diagnostic()?;
278          None
279        }
280      },
281      None => Some(self.to_output()?),
282    };
283
284    if let Some(out) = out {
285      print!("{out}");
286    }
287    Ok(())
288  }
289
290  #[cfg(all(
291    not(feature = "json"),
292    not(feature = "toml"),
293    not(feature = "yaml"),
294    not(feature = "csv")
295  ))]
296  fn output(&self, _format: Option<OutputFormat>) -> Result<()>
297  where
298    Self: Serialize + Debug,
299  {
300    Ok(())
301  }
302
303  #[cfg(any(feature = "json", feature = "toml", feature = "yaml", feature = "csv"))]
304  fn output_multiple(results: &Vec<Self>, format: Option<OutputFormat>) -> Result<()>
305  where
306    Self: Sized + Serialize + Debug,
307  {
308    let out = match format {
309      Some(format) => match format {
310        #[cfg(feature = "json")]
311        OutputFormat::Json => Some(serde_json::to_string_pretty(results).into_diagnostic()?),
312        #[cfg(feature = "toml")]
313        OutputFormat::Toml => Some(toml::to_string(results).into_diagnostic()?),
314        #[cfg(feature = "yaml")]
315        OutputFormat::Yaml => Some(serde_yaml::to_string(results).into_diagnostic()?),
316        #[cfg(feature = "csv")]
317        OutputFormat::Csv => {
318          let mut wtr = csv::Writer::from_writer(std::io::stdout());
319          for result in results {
320            wtr.serialize(result).into_diagnostic()?;
321          }
322          wtr.flush().into_diagnostic()?;
323          None
324        }
325        #[cfg(feature = "csv")]
326        OutputFormat::Tsv => {
327          let mut wtr = csv::WriterBuilder::new()
328            .delimiter(b'\t')
329            .quote_style(csv::QuoteStyle::NonNumeric)
330            .from_writer(std::io::stdout());
331          for result in results {
332            wtr.serialize(result).into_diagnostic()?;
333          }
334          wtr.flush().into_diagnostic()?;
335          None
336        }
337      },
338      None => {
339        if !results.is_empty() {
340          let mut table = vec![results.iter().next().unwrap().to_table_header()];
341          table.append(
342            &mut results
343              .iter()
344              .map(|row| row.to_table().expect("Formating data table"))
345              .collect(),
346          ); // FIXME: replace expect by something better from miette
347          display_table(&table, TableConfig::default());
348        }
349        None
350      }
351    };
352    if let Some(out) = out {
353      println!("{out}");
354    }
355    Ok(())
356  }
357
358  #[cfg(all(
359    not(feature = "json"),
360    not(feature = "toml"),
361    not(feature = "yaml"),
362    not(feature = "csv")
363  ))]
364  fn output_multiple(_results: &Vec<Self>, _format: Option<OutputFormat>) -> Result<()>
365  where
366    Self: Sized + Serialize + Debug,
367  {
368    Ok(())
369  }
370}
371
372/// An empty response. Use it in `result_struct`
373#[derive(Debug, Default, Deserialize, Serialize, PrettyPrint)]
374pub struct EmptyResponse {}
375impl Api for EmptyResponse {
376  fn to_table_header(&self) -> Vec<String> {
377    vec![]
378  }
379
380  fn to_table(&self) -> Result<Vec<String>> {
381    Ok(vec![])
382  }
383
384  fn to_output(&self) -> Result<String> {
385    Ok(String::new())
386  }
387}
388
389#[derive(Deserialize)]
390pub struct DummyTryFrom;
391
392impl TryFrom<DummyTryFrom> for EmptyResponse {
393  type Error = String;
394  fn try_from(_value: DummyTryFrom) -> std::result::Result<Self, Self::Error> {
395    Err(String::new())
396  }
397}
398
399impl<T> TryFrom<DummyTryFrom> for Vec<T> {
400  type Error = String;
401  fn try_from(_value: DummyTryFrom) -> std::result::Result<Self, Self::Error> {
402    Err(String::new())
403  }
404}
405
406#[cfg(test)]
407mod tests {
408  #[test]
409  fn it_works() {
410    let result = 2 + 2;
411    assert_eq!(result, 4);
412  }
413}