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