quartz_cli/
endpoint.rs

1use colored::Colorize;
2use hyper::http::uri::InvalidUri;
3use hyper::{Body, Request, Uri};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt::Display;
7use std::io::Write;
8use std::ops::{Deref, DerefMut};
9use std::path::{Path, PathBuf};
10
11use crate::env::{Env, Variables};
12use crate::state::StateField;
13use crate::tree::Tree;
14use crate::{Ctx, PairMap};
15
16#[derive(Default, Debug, Serialize, Deserialize, Clone)]
17pub struct Query(pub HashMap<String, String>);
18
19impl Deref for Query {
20    type Target = HashMap<String, String>;
21
22    fn deref(&self) -> &Self::Target {
23        &self.0
24    }
25}
26
27impl DerefMut for Query {
28    fn deref_mut(&mut self) -> &mut Self::Target {
29        &mut self.0
30    }
31}
32
33impl Display for Query {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        for (key, value) in self.iter() {
36            writeln!(f, "{key}={value}")?;
37        }
38
39        Ok(())
40    }
41}
42
43impl PairMap<'_> for Query {
44    const NAME: &'static str = "query param";
45
46    fn map(&mut self) -> &mut HashMap<String, String> {
47        &mut self.0
48    }
49}
50
51#[derive(Default, Debug, Serialize, Deserialize, Clone)]
52pub struct Headers(pub HashMap<String, String>);
53
54impl Headers {
55    pub fn parse(file_content: &str) -> Self {
56        let mut headers = Headers::default();
57        for header in file_content.lines().filter(|line| !line.is_empty()) {
58            headers.set(header);
59        }
60        headers
61    }
62}
63
64impl Deref for Headers {
65    type Target = HashMap<String, String>;
66
67    fn deref(&self) -> &Self::Target {
68        &self.0
69    }
70}
71
72impl DerefMut for Headers {
73    fn deref_mut(&mut self) -> &mut Self::Target {
74        &mut self.0
75    }
76}
77
78impl Display for Headers {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        for (key, value) in self.iter() {
81            writeln!(f, "{key}: {value}")?;
82        }
83
84        Ok(())
85    }
86}
87
88impl PairMap<'_> for Headers {
89    const NAME: &'static str = "header";
90    const EXPECTED: &'static str = "<key>: [value]";
91
92    fn map(&mut self) -> &mut HashMap<String, String> {
93        &mut self.0
94    }
95
96    fn pair(input: &str) -> Option<(String, String)> {
97        let (key, value) = input.split_once(": ")?;
98
99        Some((key.to_string(), value.to_string()))
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct EndpointHandle {
105    /// List of ordered parent names
106    pub path: Vec<String>,
107}
108
109#[derive(Debug, Serialize, Deserialize, Clone)]
110pub struct Endpoint {
111    pub url: String,
112
113    /// HTTP Request method
114    pub method: String,
115
116    /// Query params.
117    pub query: Query,
118
119    /// List of (key, value) pairs.
120    pub headers: Headers,
121
122    /// Variable values applied from a [`Env`]
123    #[serde(skip_serializing, skip_deserializing)]
124    pub variables: Variables,
125
126    #[serde(skip_serializing, skip_deserializing)]
127    pub path: PathBuf,
128
129    #[serde(skip_serializing, skip_deserializing)]
130    pub body: Option<String>,
131}
132
133#[derive(Debug, clap::Args)]
134#[group(multiple = false)]
135pub struct ContentTypeGroup {
136    /// Use JSON data in request body with the appropriate content-type header
137    #[arg(long, value_name = "DATA")]
138    pub json: Option<Option<String>>,
139
140    /// Use raw data in request body
141    #[arg(long = "data", short = 'd', value_name = "DATA")]
142    pub raw: Option<String>,
143}
144
145#[derive(Default, Debug, clap::Args)]
146pub struct EndpointPatch {
147    /// Patch request URL
148    #[arg(long)]
149    pub url: Option<String>,
150
151    /// Patch HTTP request method
152    #[arg(short = 'X', long = "request")]
153    pub method: Option<String>,
154
155    /// Add or patch a parameter to the URL query. This argument can be passed multiple times
156    #[arg(short, long, value_name = "PARAM")]
157    pub query: Vec<String>,
158
159    /// Add or patch a header. This argument can be passed multiple times
160    #[arg(short = 'H', long = "header")]
161    pub headers: Vec<String>,
162
163    #[command(flatten)]
164    pub data: Option<ContentTypeGroup>,
165}
166
167impl EndpointPatch {
168    pub fn has_changes(&self) -> bool {
169        self.url.is_some()
170            || self.method.is_some()
171            || !self.query.is_empty()
172            || !self.headers.is_empty()
173    }
174}
175
176impl<T> From<T> for EndpointHandle
177where
178    T: AsRef<str>,
179{
180    fn from(value: T) -> Self {
181        let path: Vec<String> = value
182            .as_ref()
183            .trim_matches('/')
184            .split('/')
185            .map(|s| s.to_string())
186            .collect();
187
188        Self::new(path)
189    }
190}
191
192impl EndpointHandle {
193    /// Points to top-level quartz folder.
194    ///
195    /// This constant can be used to traverse through all handles starting
196    /// from the top one.
197    pub const QUARTZ: Self = Self { path: vec![] };
198
199    pub fn new(path: Vec<String>) -> Self {
200        Self { path }
201    }
202
203    pub fn from_state(ctx: &Ctx) -> Option<Self> {
204        if let Ok(handle) = ctx.state.get(ctx, StateField::Endpoint) {
205            if handle.is_empty() {
206                return None;
207            }
208
209            return Some(EndpointHandle::from(handle));
210        }
211
212        None
213    }
214
215    pub fn head(&self) -> String {
216        self.path.last().unwrap_or(&String::new()).clone()
217    }
218
219    pub fn dir(&self, ctx: &Ctx) -> PathBuf {
220        let mut result = ctx.path().join("endpoints");
221
222        for parent in &self.path {
223            let name = Endpoint::name_to_dir(parent);
224
225            result = result.join(name);
226        }
227
228        result
229    }
230
231    pub fn handle(&self) -> String {
232        self.path.join("/")
233    }
234
235    pub fn exists(&self, ctx: &Ctx) -> bool {
236        self.dir(ctx).exists()
237    }
238
239    /// Records files to build this endpoint with `parse` methods.
240    pub fn write(&self, ctx: &Ctx) {
241        let mut dir = ctx.path().join("endpoints");
242        for entry in &self.path {
243            dir = dir.join(Endpoint::name_to_dir(entry));
244
245            let _ = std::fs::create_dir(&dir);
246
247            let mut file = std::fs::OpenOptions::new()
248                .write(true)
249                .truncate(true)
250                .create(true)
251                .open(dir.join("spec"))
252                .unwrap();
253
254            let _ = file.write_all(entry.as_bytes());
255        }
256
257        std::fs::create_dir_all(self.dir(ctx))
258            .unwrap_or_else(|_| panic!("failed to create endpoint"));
259    }
260
261    /// Removes endpoint to make it an empty handle
262    pub fn make_empty(&self, ctx: &Ctx) {
263        if self.endpoint(ctx).is_some() {
264            let _ = std::fs::remove_file(self.dir(ctx).join("endpoint.toml"));
265            let _ = std::fs::remove_file(self.dir(ctx).join("body"));
266        }
267    }
268
269    pub fn depth(&self) -> usize {
270        self.path.len()
271    }
272
273    pub fn children(&self, ctx: &Ctx) -> Vec<EndpointHandle> {
274        let mut list = Vec::<EndpointHandle>::new();
275
276        if let Ok(paths) = std::fs::read_dir(self.dir(ctx)) {
277            for path in paths {
278                let path = path.unwrap().path();
279
280                if !path.is_dir() {
281                    continue;
282                }
283
284                if let Ok(vec) = std::fs::read(path.join("spec")) {
285                    let spec = String::from_utf8(vec).unwrap_or_else(|_| {
286                        panic!("failed to get handle");
287                    });
288
289                    let mut path = self.path.clone();
290                    path.push(spec);
291
292                    list.push(EndpointHandle::new(path))
293                }
294            }
295        }
296
297        list
298    }
299
300    #[must_use]
301    pub fn endpoint(&self, ctx: &Ctx) -> Option<Endpoint> {
302        Endpoint::from_dir(&self.dir(ctx)).ok()
303    }
304
305    pub fn replace(&mut self, from: &str, to: &str) {
306        let handle = self.handle().replace(from, to);
307        self.path = EndpointHandle::from(handle).path;
308    }
309
310    pub fn tree(self, ctx: &Ctx) -> Tree<Self> {
311        let mut tree = Tree::new(self);
312
313        for child in tree.root.value.children(ctx) {
314            let child_tree = child.tree(ctx);
315            tree.root.children.push(child_tree.root);
316        }
317
318        tree
319    }
320}
321
322impl From<&mut EndpointPatch> for Endpoint {
323    fn from(value: &mut EndpointPatch) -> Self {
324        let mut endpoint = Self::default();
325        endpoint.update(value);
326
327        endpoint
328    }
329}
330
331impl Endpoint {
332    pub fn new(path: PathBuf) -> Self {
333        Self {
334            method: String::from("GET"),
335            path,
336            ..Default::default()
337        }
338    }
339
340    pub fn name_to_dir(name: &str) -> String {
341        name.trim().replace(['/', '\\'], "-")
342    }
343
344    pub fn from_dir(dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
345        let bytes = std::fs::read(dir.join("endpoint.toml"))?;
346        let content = String::from_utf8(bytes)?;
347
348        let mut endpoint: Endpoint = toml::from_str(&content)?;
349        endpoint.path = dir.to_path_buf();
350
351        Ok(endpoint)
352    }
353
354    pub fn update(&mut self, src: &mut EndpointPatch) {
355        if let Some(method) = &mut src.method {
356            std::mem::swap(&mut self.method, method);
357        }
358
359        if let Some(url) = &mut src.url {
360            std::mem::swap(&mut self.url, url);
361        }
362
363        for input in &src.query {
364            self.query.set(input);
365        }
366
367        for input in &src.headers {
368            self.headers.set(input);
369        }
370
371        for input in &src.query {
372            self.query.set(input);
373        }
374
375        if let Some(data) = &src.data {
376            if let Some(maybe_json) = &data.json {
377                self.headers
378                    .insert("Content-type".into(), "application/json".into());
379
380                if let Some(json) = maybe_json {
381                    self.body = Some(json.to_owned());
382                }
383            } else if let Some(raw) = &data.raw {
384                self.body = Some(raw.to_owned());
385            }
386        }
387    }
388
389    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
390        toml::to_string(&self)
391    }
392
393    pub fn load_body(&mut self) -> Option<&String> {
394        match std::fs::read_to_string(self.path.join("body")) {
395            Ok(mut content) => {
396                for (key, value) in self.variables.iter() {
397                    let key_match = format!("{{{{{}}}}}", key);
398
399                    content = content.replace(&key_match, value);
400                }
401
402                if content.trim().is_empty() {
403                    return None;
404                }
405
406                self.body = Some(content.to_owned());
407                self.body.as_ref()
408            }
409            Err(_) => None,
410        }
411    }
412
413    pub fn body(&mut self) -> Option<&String> {
414        if self.body.is_some() {
415            self.body.as_ref()
416        } else {
417            self.load_body()
418        }
419    }
420
421    pub fn set_handle(&mut self, ctx: &Ctx, handle: &EndpointHandle) {
422        self.path = handle.dir(ctx).to_path_buf();
423    }
424
425    pub fn parent(&self) -> Option<Self> {
426        let mut path = self.path.clone();
427
428        if path.pop() {
429            Self::from_dir(&path).ok()
430        } else {
431            None
432        }
433    }
434
435    /// Inherits parent URL when it starts with "**".
436    pub fn resolve_url(&mut self) {
437        if !self.url.starts_with("**") {
438            return;
439        }
440
441        if let Some(mut parent) = self.parent() {
442            parent.resolve_url();
443            if parent.url.ends_with('/') {
444                // Prevents "//" in the URL after merging
445                parent.url.pop();
446            }
447
448            if self.url.is_empty() {
449                self.url = parent.url;
450            } else {
451                self.url = self.url.replacen("**", &parent.url, 1);
452            }
453        }
454    }
455
456    pub fn apply_env(&mut self, env: &Env) {
457        self.resolve_url();
458
459        for (key, value) in env.variables.iter() {
460            let key_match = format!("{{{{{}}}}}", key); // {{key}}
461
462            self.url = self.url.replace(&key_match, value);
463            self.method = self.method.replace(&key_match, value);
464
465            *self.headers = self
466                .headers
467                .iter()
468                .map(|(h_key, h_value)| {
469                    let h_key = &h_key.replace(&key_match, value);
470                    let h_value = &h_value.replace(&key_match, value);
471
472                    (h_key.clone(), h_value.clone())
473                })
474                .collect();
475
476            *self.query = self
477                .query
478                .iter()
479                .map(|(h_key, h_value)| {
480                    let h_key = &h_key.replace(&key_match, value);
481                    let h_value = &h_value.replace(&key_match, value);
482
483                    (h_key.clone(), h_value.clone())
484                })
485                .collect();
486        }
487
488        self.variables = env.variables.clone();
489    }
490
491    pub fn full_url(&self) -> Result<Uri, InvalidUri> {
492        let query_string = self.query_string();
493
494        let mut url = self.url.clone();
495
496        if !query_string.is_empty() {
497            let delimiter = if self.url.contains('?') { '&' } else { '?' };
498            url.push(delimiter);
499            url.push_str(&query_string);
500        }
501
502        let result = Uri::try_from(&url);
503
504        if result.is_err() && !url.contains("://") {
505            let mut scheme = "http://".to_owned();
506            scheme.push_str(&url);
507
508            return Uri::try_from(scheme);
509        }
510
511        result
512    }
513
514    /// Returns the a [`Request`] consuming struct.
515    pub fn into_request(mut self) -> Result<Request<Body>, hyper::http::Error> {
516        let mut builder = hyper::Request::builder().uri(&self.full_url()?);
517
518        if let Ok(method) = hyper::Method::from_bytes(self.method.as_bytes()) {
519            builder = builder.method(method);
520        }
521
522        for (key, value) in self.headers.iter() {
523            builder = builder.header(key, value);
524        }
525
526        if let Some(body) = self.body() {
527            builder.body(body.to_owned().into())
528        } else {
529            builder.body(Body::empty())
530        }
531    }
532
533    pub fn colored_method(&self) -> colored::ColoredString {
534        colored_method(&self.method)
535    }
536
537    /// Return a query string based off of defined queries.
538    ///
539    /// ## Example
540    ///
541    /// A hash map composed of:
542    ///
543    /// ```toml
544    /// [query]
545    /// v = 9000
546    /// fields = "lorem,ipsum"
547    /// ```
548    ///
549    /// would return: v=9000&fields=lorem,ipsum
550    pub fn query_string(&self) -> String {
551        let mut result: Vec<String> = Vec::new();
552
553        for (key, value) in self.query.iter() {
554            result.push(format!("{key}={value}"));
555        }
556
557        result.sort();
558        result.join("&")
559    }
560
561    pub fn write(&mut self) {
562        let toml_content = self
563            .to_toml()
564            .unwrap_or_else(|_| panic!("failed to generate settings"));
565
566        let mut file = std::fs::OpenOptions::new()
567            .write(true)
568            .create(true)
569            .truncate(true)
570            .open(self.path.join("endpoint.toml"))
571            .unwrap_or_else(|_| panic!("failed to open config file"));
572
573        file.write_all(toml_content.as_bytes())
574            .unwrap_or_else(|_| panic!("failed to write to config file"));
575    }
576}
577
578impl Default for Endpoint {
579    fn default() -> Self {
580        Self {
581            method: String::from("GET"),
582            url: Default::default(),
583            headers: Default::default(),
584            variables: Default::default(),
585            query: Default::default(),
586            path: Default::default(),
587            body: Default::default(),
588        }
589    }
590}
591
592pub fn colored_method(value: &str) -> colored::ColoredString {
593    match value {
594        "GET" => value.blue(),
595        "POST" => value.green(),
596        "PUT" => value.yellow(),
597        "PATCH" => value.yellow(),
598        "DELETE" => value.red(),
599        "OPTIONS" => value.cyan(),
600        "HEAD" => value.cyan(),
601        "---" => value.dimmed(),
602        _ => value.white(),
603    }
604}