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::{fmt::Debug, marker::PhantomData};
188
189extern crate crud_api_derive;
190#[doc(hidden)]
191pub mod cli;
192#[doc(hidden)]
193pub mod completions;
194#[doc(hidden)]
195pub mod error;
196mod formats;
197#[doc(hidden)]
198pub mod http;
199#[doc(hidden)]
200pub mod settings;
201
202#[doc(hidden)]
203pub struct ApiInputOptions {
204  pub conflicts_with_all: Vec<Id>,
205}
206
207#[doc(hidden)]
208pub trait ApiInput {
209  /// Generate the clap command declatations.
210  fn clap(app: Command, options: Option<ApiInputOptions>) -> Command;
211  fn from_clap_matches(matches: &ArgMatches) -> Result<Self>
212  where
213    Self: Sized;
214}
215
216#[doc(hidden)]
217#[async_trait]
218pub trait Query {
219  async fn query<P, T, R, Q>(
220    &self,
221    payload: Option<P>,
222    argument: Option<Q>,
223    t: Option<PhantomData<T>>,
224  ) -> Result<R>
225  where
226    P: Send + Serialize + Debug,
227    T: TryInto<R, Error = String> + DeserializeOwned + Send,
228    R: Send + DeserializeOwned + Debug + Default,
229    Q: Send + Serialize + Debug;
230  async fn stream<P, Q>(
231    &self,
232    payload: Option<P>,
233    argument: Option<Q>,
234    filename: Option<String>,
235  ) -> Result<()>
236  where
237    P: Send + Serialize + Debug,
238    Q: Send + Serialize + Debug;
239}
240
241#[doc(hidden)]
242pub trait Api {
243  fn to_table_header(&self) -> Vec<String>;
244  fn to_table(&self) -> Result<Vec<String>>;
245  fn to_output(&self) -> Result<String>;
246
247  #[cfg(any(feature = "json", feature = "toml", feature = "yaml", feature = "csv"))]
248  fn output(&self, format: Option<OutputFormat>) -> Result<()>
249  where
250    Self: Serialize + Debug,
251  {
252    let out = match format {
253      Some(format) => match format {
254        #[cfg(feature = "json")]
255        OutputFormat::Json => Some(serde_json::to_string_pretty(self).into_diagnostic()?),
256        #[cfg(feature = "toml")]
257        OutputFormat::Toml => Some(toml::to_string(self).into_diagnostic()?),
258        #[cfg(feature = "yaml")]
259        OutputFormat::Yaml => Some(serde_yaml::to_string(self).into_diagnostic()?),
260        #[cfg(feature = "csv")]
261        OutputFormat::Csv => {
262          let mut wtr = csv::Writer::from_writer(std::io::stdout());
263          wtr.serialize(self).into_diagnostic()?;
264          wtr.flush().into_diagnostic()?;
265          None
266        }
267        #[cfg(feature = "csv")]
268        OutputFormat::Tsv => {
269          let mut wtr = csv::WriterBuilder::new()
270            .delimiter(b'\t')
271            .quote_style(csv::QuoteStyle::NonNumeric)
272            .from_writer(std::io::stdout());
273          wtr.serialize(self).into_diagnostic()?;
274          wtr.flush().into_diagnostic()?;
275          None
276        }
277      },
278      None => Some(self.to_output()?),
279    };
280
281    if let Some(out) = out {
282      print!("{out}");
283    }
284    Ok(())
285  }
286
287  #[cfg(all(
288    not(feature = "json"),
289    not(feature = "toml"),
290    not(feature = "yaml"),
291    not(feature = "csv")
292  ))]
293  fn output(&self, _format: Option<OutputFormat>) -> Result<()>
294  where
295    Self: Serialize + Debug,
296  {
297    Ok(())
298  }
299
300  #[cfg(any(feature = "json", feature = "toml", feature = "yaml", feature = "csv"))]
301  fn output_multiple(results: &Vec<Self>, format: Option<OutputFormat>) -> Result<()>
302  where
303    Self: Sized + Serialize + Debug,
304  {
305    let out = match format {
306      Some(format) => match format {
307        #[cfg(feature = "json")]
308        OutputFormat::Json => Some(serde_json::to_string_pretty(results).into_diagnostic()?),
309        #[cfg(feature = "toml")]
310        OutputFormat::Toml => Some(toml::to_string(results).into_diagnostic()?),
311        #[cfg(feature = "yaml")]
312        OutputFormat::Yaml => Some(serde_yaml::to_string(results).into_diagnostic()?),
313        #[cfg(feature = "csv")]
314        OutputFormat::Csv => {
315          let mut wtr = csv::Writer::from_writer(std::io::stdout());
316          for result in results {
317            wtr.serialize(result).into_diagnostic()?;
318          }
319          wtr.flush().into_diagnostic()?;
320          None
321        }
322        #[cfg(feature = "csv")]
323        OutputFormat::Tsv => {
324          let mut wtr = csv::WriterBuilder::new()
325            .delimiter(b'\t')
326            .quote_style(csv::QuoteStyle::NonNumeric)
327            .from_writer(std::io::stdout());
328          for result in results {
329            wtr.serialize(result).into_diagnostic()?;
330          }
331          wtr.flush().into_diagnostic()?;
332          None
333        }
334      },
335      None => {
336        if !results.is_empty() {
337          let mut table = vec![results.iter().next().unwrap().to_table_header()];
338          table.append(
339            &mut results
340              .iter()
341              .map(|row| row.to_table().expect("Formating data table"))
342              .collect(),
343          ); // FIXME: replace expect by something better from miette
344          display_table(&table, TableConfig::default());
345        }
346        None
347      }
348    };
349    if let Some(out) = out {
350      println!("{out}");
351    }
352    Ok(())
353  }
354
355  #[cfg(all(
356    not(feature = "json"),
357    not(feature = "toml"),
358    not(feature = "yaml"),
359    not(feature = "csv")
360  ))]
361  fn output_multiple(_results: &Vec<Self>, _format: Option<OutputFormat>) -> Result<()>
362  where
363    Self: Sized + Serialize + Debug,
364  {
365    Ok(())
366  }
367}
368
369/// An empty response. Use it in `result_struct`
370#[derive(Debug, Default, Deserialize, Serialize, PrettyPrint)]
371pub struct EmptyResponse {}
372impl Api for EmptyResponse {
373  fn to_table_header(&self) -> Vec<String> {
374    vec![]
375  }
376
377  fn to_table(&self) -> Result<Vec<String>> {
378    Ok(vec![])
379  }
380
381  fn to_output(&self) -> Result<String> {
382    Ok(String::new())
383  }
384}
385
386#[derive(Deserialize)]
387pub struct DummyTryFrom;
388
389impl TryFrom<DummyTryFrom> for EmptyResponse {
390  type Error = String;
391  fn try_from(_value: DummyTryFrom) -> std::result::Result<Self, Self::Error> {
392    Err(String::new())
393  }
394}
395
396impl<T> TryFrom<DummyTryFrom> for Vec<T> {
397  type Error = String;
398  fn try_from(_value: DummyTryFrom) -> std::result::Result<Self, Self::Error> {
399    Err(String::new())
400  }
401}
402
403#[cfg(test)]
404mod tests {
405  #[test]
406  fn it_works() {
407    let result = 2 + 2;
408    assert_eq!(result, 4);
409  }
410}