leyline/core/response/
link.rs1use http::header::LINK;
2use url::Url;
3
4use super::Response;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub struct Link {
9 pub url: Url,
10 pub rel: Vec<String>,
11 pub params: Vec<(String, String)>,
12}
13
14impl Link {
15 pub fn has_rel(&self, rel: &str) -> bool {
16 self.rel.iter().any(|r| r.eq_ignore_ascii_case(rel))
17 }
18}
19
20impl Response {
21 pub fn links(&self) -> Vec<Link> {
22 let mut out = Vec::new();
23 for value in self.headers.get_all(LINK) {
24 if let Ok(text) = value.to_str() {
25 out.extend(parse_links(text, &self.url));
26 }
27 }
28 out
29 }
30
31 pub fn link(&self, rel: &str) -> Option<Url> {
32 self.links()
33 .into_iter()
34 .find(|link| link.has_rel(rel))
35 .map(|link| link.url)
36 }
37}
38
39pub(crate) fn parse_links(text: &str, base: &Url) -> Vec<Link> {
40 let mut out = Vec::new();
41 Parser::new(text).parse_into(base, &mut out);
42 out
43}
44
45struct Parser<'a> {
46 src: &'a str,
47 pos: usize,
48}
49
50impl<'a> Parser<'a> {
51 fn new(src: &'a str) -> Self {
52 Self { src, pos: 0 }
53 }
54
55 fn peek(&self) -> Option<u8> {
56 self.src.as_bytes().get(self.pos).copied()
57 }
58
59 fn skip_ws(&mut self) {
60 while matches!(self.peek(), Some(b' ' | b'\t')) {
61 self.pos += 1;
62 }
63 }
64
65 fn eat(&mut self, byte: u8) -> bool {
66 self.skip_ws();
67 let found = self.peek() == Some(byte);
68 if found {
69 self.pos += 1;
70 }
71 found
72 }
73
74 fn parse_into(mut self, base: &Url, out: &mut Vec<Link>) {
75 loop {
76 while self.eat(b',') {}
77 if self.peek().is_none() {
78 return;
79 }
80 match self.link_value(base) {
81 Some(link) => out.push(link),
82 None => self.skip_value(),
83 }
84 }
85 }
86
87 fn link_value(&mut self, base: &Url) -> Option<Link> {
88 if !self.eat(b'<') {
89 return None;
90 }
91 let end = self.src[self.pos..].find('>')? + self.pos;
92 let target = &self.src[self.pos..end];
93 self.pos = end + 1;
94 let mut rel = None;
95 let mut params = Vec::new();
96 while self.eat(b';') {
97 let Some((name, value)) = self.param() else {
98 continue;
99 };
100 if name.eq_ignore_ascii_case("rel") {
101 rel.get_or_insert(value);
102 } else {
103 params.push((name, value));
104 }
105 }
106 let url = base.join(target.trim()).ok()?;
107 let rel = rel
108 .map(|r| r.split_ascii_whitespace().map(str::to_owned).collect())
109 .unwrap_or_default();
110 Some(Link { url, rel, params })
111 }
112
113 fn param(&mut self) -> Option<(String, String)> {
114 self.skip_ws();
115 let name = self.token();
116 if name.is_empty() {
117 return None;
118 }
119 if !self.eat(b'=') {
120 return Some((name.to_ascii_lowercase(), String::new()));
121 }
122 self.skip_ws();
123 let value = if self.peek() == Some(b'"') {
124 self.quoted()
125 } else {
126 self.token().to_owned()
127 };
128 Some((name.to_ascii_lowercase(), value))
129 }
130
131 fn token(&mut self) -> &'a str {
132 let start = self.pos;
133 while self
134 .peek()
135 .is_some_and(|b| !matches!(b, b';' | b',' | b'=' | b' ' | b'\t' | b'"'))
136 {
137 self.pos += 1;
138 }
139 &self.src[start..self.pos]
140 }
141
142 fn quoted(&mut self) -> String {
143 self.pos += 1;
144 let mut out = String::new();
145 let mut chars = self.src[self.pos..].char_indices();
146 while let Some((offset, c)) = chars.next() {
147 match c {
148 '"' => {
149 self.pos += offset + 1;
150 return out;
151 }
152 '\\' => out.extend(chars.next().map(|(_, escaped)| escaped)),
153 other => out.push(other),
154 }
155 }
156 self.pos = self.src.len();
157 out
158 }
159
160 fn skip_value(&mut self) {
161 while let Some(b) = self.peek() {
162 match b {
163 b',' => return,
164 b'"' => {
165 self.quoted();
166 }
167 b'<' => match self.src[self.pos..].find('>') {
168 Some(end) => self.pos += end + 1,
169 None => self.pos = self.src.len(),
170 },
171 _ => self.pos += 1,
172 }
173 }
174 }
175}