Skip to main content

stduritemplate/
lib.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::fmt;
4
5pub fn expand(
6    template: &str,
7    substitutions: &HashMap<String, Value>,
8) -> Result<String, StdUriTemplateError> {
9    expand_impl(template, substitutions)
10}
11
12#[derive(Debug, Clone)]
13pub enum Value {
14    String(String),
15    Bool(bool),
16    Integer(i64),
17    Float(f64),
18    List(Vec<Value>),
19    Map(Vec<(String, Value)>),
20}
21
22#[derive(Debug)]
23pub struct StdUriTemplateError {
24    message: String,
25}
26
27impl fmt::Display for StdUriTemplateError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "{}", self.message)
30    }
31}
32
33impl std::error::Error for StdUriTemplateError {}
34
35impl StdUriTemplateError {
36    fn new(message: String) -> Self {
37        StdUriTemplateError { message }
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq)]
42enum Operator {
43    NoOp,
44    Plus,
45    Hash,
46    Dot,
47    Slash,
48    Semicolon,
49    QuestionMark,
50    Amp,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq)]
54enum SubstitutionType {
55    Empty,
56    String,
57    List,
58    Map,
59}
60
61fn validate_literal(c: char, col: usize) -> Result<(), StdUriTemplateError> {
62    match c {
63        '+' | '#' | '/' | ';' | '?' | '&' | ' ' | '!' | '=' | '$' | '|' | '*' | ':' | '~'
64        | '-' => Err(StdUriTemplateError::new(format!(
65            "Illegal character identified in the token at col:{}",
66            col
67        ))),
68        _ => Ok(()),
69    }
70}
71
72fn get_max_char(buffer: &str, col: usize) -> Result<i32, StdUriTemplateError> {
73    if buffer.is_empty() {
74        return Ok(-1);
75    }
76
77    buffer.parse::<i32>().map_err(|_| {
78        StdUriTemplateError::new(format!("Cannot parse max chars at col:{}", col))
79    })
80}
81
82fn get_operator(
83    c: char,
84    token: &mut String,
85    col: usize,
86) -> Result<Operator, StdUriTemplateError> {
87    match c {
88        '+' => Ok(Operator::Plus),
89        '#' => Ok(Operator::Hash),
90        '.' => Ok(Operator::Dot),
91        '/' => Ok(Operator::Slash),
92        ';' => Ok(Operator::Semicolon),
93        '?' => Ok(Operator::QuestionMark),
94        '&' => Ok(Operator::Amp),
95        _ => {
96            validate_literal(c, col)?;
97            token.push(c);
98            Ok(Operator::NoOp)
99        }
100    }
101}
102
103fn expand_impl(
104    template: &str,
105    substitutions: &HashMap<String, Value>,
106) -> Result<String, StdUriTemplateError> {
107    let mut result = String::with_capacity(template.len() * 2);
108
109    let mut to_token = false;
110    let mut token = String::new();
111
112    let mut operator: Option<Operator> = None;
113    let mut composite = false;
114    let mut to_max_char_buffer = false;
115    let mut max_char_buffer = String::with_capacity(3);
116    let mut first_token = true;
117
118    for (i, character) in template.chars().enumerate() {
119        match character {
120            '{' => {
121                to_token = true;
122                token.clear();
123                first_token = true;
124            }
125            '}' => {
126                if to_token {
127                    let max_char = get_max_char(&max_char_buffer, i)?;
128                    let expanded = expand_token(
129                        operator.unwrap_or(Operator::NoOp),
130                        &token,
131                        composite,
132                        max_char,
133                        first_token,
134                        substitutions,
135                        &mut result,
136                        i,
137                    )?;
138                    if expanded && first_token {
139                        first_token = false;
140                    }
141                    to_token = false;
142                    token.clear();
143                    operator = None;
144                    composite = false;
145                    to_max_char_buffer = false;
146                    max_char_buffer.clear();
147                } else {
148                    return Err(StdUriTemplateError::new(format!(
149                        "Failed to expand token, invalid at col:{}",
150                        i
151                    )));
152                }
153            }
154            ',' if to_token => {
155                let max_char = get_max_char(&max_char_buffer, i)?;
156                let expanded = expand_token(
157                    operator.unwrap_or(Operator::NoOp),
158                    &token,
159                    composite,
160                    max_char,
161                    first_token,
162                    substitutions,
163                    &mut result,
164                    i,
165                )?;
166                if expanded && first_token {
167                    first_token = false;
168                }
169                token.clear();
170                composite = false;
171                to_max_char_buffer = false;
172                max_char_buffer.clear();
173            }
174            _ => {
175                if to_token {
176                    if operator.is_none() {
177                        operator = Some(get_operator(character, &mut token, i)?);
178                    } else if to_max_char_buffer {
179                        if character.is_ascii_digit() {
180                            max_char_buffer.push(character);
181                        } else {
182                            return Err(StdUriTemplateError::new(format!(
183                                "Illegal character identified in the token at col:{}",
184                                i
185                            )));
186                        }
187                    } else {
188                        match character {
189                            ':' => {
190                                to_max_char_buffer = true;
191                                max_char_buffer.clear();
192                            }
193                            '*' => {
194                                composite = true;
195                            }
196                            _ => {
197                                validate_literal(character, i)?;
198                                token.push(character);
199                            }
200                        }
201                    }
202                } else {
203                    result.push(character);
204                }
205            }
206        }
207    }
208
209    if !to_token {
210        Ok(result)
211    } else {
212        Err(StdUriTemplateError::new("Unterminated token".to_string()))
213    }
214}
215
216fn add_prefix(op: Operator, result: &mut String) {
217    match op {
218        Operator::Hash => result.push('#'),
219        Operator::Dot => result.push('.'),
220        Operator::Slash => result.push('/'),
221        Operator::Semicolon => result.push(';'),
222        Operator::QuestionMark => result.push('?'),
223        Operator::Amp => result.push('&'),
224        _ => {}
225    }
226}
227
228fn add_separator(op: Operator, result: &mut String) {
229    match op {
230        Operator::Dot => result.push('.'),
231        Operator::Slash => result.push('/'),
232        Operator::Semicolon => result.push(';'),
233        Operator::QuestionMark | Operator::Amp => result.push('&'),
234        _ => result.push(','),
235    }
236}
237
238fn add_value(op: Operator, token: &str, value: &str, result: &mut String, max_char: i32) {
239    match op {
240        Operator::Plus | Operator::Hash => {
241            add_expanded_value(None, value, result, max_char, false);
242        }
243        Operator::QuestionMark | Operator::Amp => {
244            result.push_str(token);
245            result.push('=');
246            add_expanded_value(None, value, result, max_char, true);
247        }
248        Operator::Semicolon => {
249            result.push_str(token);
250            add_expanded_value(Some("="), value, result, max_char, true);
251        }
252        Operator::Dot | Operator::Slash | Operator::NoOp => {
253            add_expanded_value(None, value, result, max_char, true);
254        }
255    }
256}
257
258fn add_value_element(op: Operator, _token: &str, value: &str, result: &mut String, max_char: i32) {
259    match op {
260        Operator::Plus | Operator::Hash => {
261            add_expanded_value(None, value, result, max_char, false);
262        }
263        Operator::QuestionMark
264        | Operator::Amp
265        | Operator::Semicolon
266        | Operator::Dot
267        | Operator::Slash
268        | Operator::NoOp => {
269            add_expanded_value(None, value, result, max_char, true);
270        }
271    }
272}
273
274fn is_iprivate(cp: char) -> bool {
275    (0xE000..=0xF8FF).contains(&(cp as u32))
276}
277
278fn is_ucschar(cp: char) -> bool {
279    let code = cp as u32;
280    (0xA0..=0xD7FF).contains(&code)
281        || (0xF900..=0xFDCF).contains(&code)
282        || (0xFDF0..=0xFFEF).contains(&code)
283}
284
285fn is_unreserved(c: char) -> bool {
286    c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '~'
287}
288
289fn percent_encode_char(c: char, result: &mut String) {
290    let mut buf = [0u8; 4];
291    let encoded = c.encode_utf8(&mut buf);
292    for byte in encoded.as_bytes() {
293        result.push('%');
294        result.push(to_hex_digit(byte >> 4));
295        result.push(to_hex_digit(byte & 0x0F));
296    }
297}
298
299fn url_encode_char(c: char, result: &mut String) {
300    if is_unreserved(c) {
301        result.push(c);
302    } else {
303        percent_encode_char(c, result);
304    }
305}
306
307fn to_hex_digit(nibble: u8) -> char {
308    match nibble {
309        0..=9 => (b'0' + nibble) as char,
310        10..=15 => (b'A' + nibble - 10) as char,
311        _ => unreachable!(),
312    }
313}
314
315fn add_expanded_value(
316    prefix: Option<&str>,
317    value: &str,
318    result: &mut String,
319    max_char: i32,
320    replace_reserved: bool,
321) {
322    let max = if max_char != -1 {
323        max_char as usize
324    } else {
325        usize::MAX
326    };
327
328    let mut to_reserved = false;
329    let mut reserved_buffer = String::with_capacity(3);
330    let mut to_append = String::with_capacity(12);
331    let mut prefix_pending = prefix;
332
333    for character in value.chars().take(max) {
334        if let Some(p) = prefix_pending.take() {
335            result.push_str(p);
336        }
337
338        if character == '%' && !replace_reserved {
339            to_reserved = true;
340            reserved_buffer.clear();
341        }
342
343        to_append.clear();
344        if replace_reserved || is_ucschar(character) || is_iprivate(character) {
345            url_encode_char(character, &mut to_append);
346        } else if !character.is_ascii() {
347            percent_encode_char(character, &mut to_append);
348        } else {
349            to_append.push(character);
350        }
351
352        if to_reserved {
353            reserved_buffer.push_str(&to_append);
354
355            if reserved_buffer.len() == 3 {
356                let is_encoded = is_valid_percent_encoded(&reserved_buffer);
357
358                if is_encoded {
359                    result.push_str(&reserved_buffer);
360                } else {
361                    result.push_str("%25");
362                    result.push_str(&reserved_buffer[1..]);
363                }
364                to_reserved = false;
365                reserved_buffer.clear();
366            }
367        } else if character == ' ' {
368            result.push_str("%20");
369        } else if character == '%' {
370            result.push_str("%25");
371        } else {
372            result.push_str(&to_append);
373        }
374    }
375
376    if to_reserved {
377        result.push_str("%25");
378        result.push_str(&reserved_buffer[1..]);
379    }
380}
381
382fn is_valid_percent_encoded(s: &str) -> bool {
383    let b = s.as_bytes();
384    b.len() == 3 && b[0] == b'%' && b[1].is_ascii_hexdigit() && b[2].is_ascii_hexdigit()
385}
386
387fn get_substitution_type(
388    value: Option<&Value>,
389    _col: usize,
390) -> Result<SubstitutionType, StdUriTemplateError> {
391    match value {
392        None => Ok(SubstitutionType::Empty),
393        Some(v) => match v {
394            Value::String(_) | Value::Bool(_) | Value::Integer(_) | Value::Float(_) => {
395                Ok(SubstitutionType::String)
396            }
397            Value::List(_) => Ok(SubstitutionType::List),
398            Value::Map(_) => Ok(SubstitutionType::Map),
399        },
400    }
401}
402
403fn is_empty(subst_type: SubstitutionType, value: &Value) -> bool {
404    match subst_type {
405        SubstitutionType::String => false,
406        SubstitutionType::List => {
407            if let Value::List(l) = value {
408                l.is_empty()
409            } else {
410                true
411            }
412        }
413        SubstitutionType::Map => {
414            if let Value::Map(m) = value {
415                m.is_empty()
416            } else {
417                true
418            }
419        }
420        SubstitutionType::Empty => true,
421    }
422}
423
424fn convert_native_types(value: &Value) -> Result<Cow<'_, str>, StdUriTemplateError> {
425    match value {
426        Value::String(s) => Ok(Cow::Borrowed(s)),
427        Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
428        Value::Integer(i) => Ok(Cow::Owned(i.to_string())),
429        Value::Float(f) => {
430            if *f == (*f as i64) as f64 && f.is_finite() {
431                Ok(Cow::Owned((*f as i64).to_string()))
432            } else {
433                Ok(Cow::Owned(f.to_string()))
434            }
435        }
436        Value::List(_) | Value::Map(_) => Err(StdUriTemplateError::new(format!(
437            "Illegal class passed as substitution, found {:?}",
438            value
439        ))),
440    }
441}
442
443#[allow(clippy::too_many_arguments)]
444fn expand_token(
445    operator: Operator,
446    token: &str,
447    composite: bool,
448    max_char: i32,
449    first_token: bool,
450    substitutions: &HashMap<String, Value>,
451    result: &mut String,
452    col: usize,
453) -> Result<bool, StdUriTemplateError> {
454    if token.is_empty() {
455        return Err(StdUriTemplateError::new(format!(
456            "Found an empty token at col:{}",
457            col
458        )));
459    }
460
461    let value = substitutions.get(token);
462    let subst_type = get_substitution_type(value, col)?;
463    if subst_type == SubstitutionType::Empty {
464        return Ok(false);
465    }
466
467    let value = value.unwrap();
468    if is_empty(subst_type, value) {
469        return Ok(false);
470    }
471
472    if first_token {
473        add_prefix(operator, result);
474    } else {
475        add_separator(operator, result);
476    }
477
478    match subst_type {
479        SubstitutionType::String => {
480            add_string_value(operator, token, value, result, max_char)?;
481        }
482        SubstitutionType::List => {
483            add_list_value(operator, token, value, result, max_char, composite)?;
484        }
485        SubstitutionType::Map => {
486            add_map_value(operator, token, value, result, max_char, composite)?;
487        }
488        SubstitutionType::Empty => {}
489    }
490
491    Ok(true)
492}
493
494fn add_string_value(
495    operator: Operator,
496    token: &str,
497    value: &Value,
498    result: &mut String,
499    max_char: i32,
500) -> Result<(), StdUriTemplateError> {
501    let s = convert_native_types(value)?;
502    add_value(operator, token, &s, result, max_char);
503    Ok(())
504}
505
506fn add_list_value(
507    operator: Operator,
508    token: &str,
509    value: &Value,
510    result: &mut String,
511    max_char: i32,
512    composite: bool,
513) -> Result<(), StdUriTemplateError> {
514    if let Value::List(list) = value {
515        let mut first = true;
516        for v in list {
517            let s = convert_native_types(v)?;
518            if first {
519                add_value(operator, token, &s, result, max_char);
520                first = false;
521            } else if composite {
522                add_separator(operator, result);
523                add_value(operator, token, &s, result, max_char);
524            } else {
525                result.push(',');
526                add_value_element(operator, token, &s, result, max_char);
527            }
528        }
529    }
530    Ok(())
531}
532
533fn add_map_value(
534    operator: Operator,
535    token: &str,
536    value: &Value,
537    result: &mut String,
538    max_char: i32,
539    composite: bool,
540) -> Result<(), StdUriTemplateError> {
541    if max_char != -1 {
542        return Err(StdUriTemplateError::new(
543            "Value trimming is not allowed on Maps".to_string(),
544        ));
545    }
546
547    if let Value::Map(map) = value {
548        let mut first = true;
549        for (key, val) in map {
550            let v = convert_native_types(val)?;
551            if composite {
552                if !first {
553                    add_separator(operator, result);
554                }
555                add_value_element(operator, token, key, result, max_char);
556                result.push('=');
557            } else {
558                if first {
559                    add_value(operator, token, key, result, max_char);
560                } else {
561                    result.push(',');
562                    add_value_element(operator, token, key, result, max_char);
563                }
564                result.push(',');
565            }
566            add_value_element(operator, token, &v, result, max_char);
567            first = false;
568        }
569    }
570
571    Ok(())
572}