open_library_api_rs/
validation.rs1use crate::error::{Error, Result};
3
4const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024; pub(crate) fn max_response_bytes() -> u64 {
7 MAX_RESPONSE_BYTES
8}
9
10fn validate_olid(s: &str, suffix: char) -> Result<()> {
13 if !s.starts_with("OL") {
14 return Err(Error::InvalidInput(format!(
15 "OLID must start with 'OL', got: {s}"
16 )));
17 }
18 if !s.ends_with(suffix) {
19 return Err(Error::InvalidInput(format!(
20 "OLID must end with '{suffix}', got: {s}"
21 )));
22 }
23 let middle = &s[2..s.len() - 1];
24 if middle.is_empty() || !middle.chars().all(|c| c.is_ascii_digit()) {
25 return Err(Error::InvalidInput(format!(
26 "OLID middle section must be all digits, got: {s}"
27 )));
28 }
29 Ok(())
30}
31
32pub fn validate_work_id(id: &str) -> Result<()> {
33 validate_olid(id, 'W')
34}
35
36pub fn validate_edition_id(id: &str) -> Result<()> {
37 validate_olid(id, 'M')
38}
39
40pub fn validate_author_id(id: &str) -> Result<()> {
41 validate_olid(id, 'A')
42}
43
44pub fn validate_isbn(isbn: &str) -> Result<()> {
47 let digits: String = isbn.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
48 match digits.len() {
49 10 => validate_isbn10(&digits),
50 13 => validate_isbn13(&digits),
51 _ => Err(Error::InvalidInput(format!(
52 "ISBN must be 10 or 13 characters, got {} characters: {isbn}",
53 digits.len()
54 ))),
55 }
56}
57
58fn validate_isbn10(s: &str) -> Result<()> {
59 let bytes = s.as_bytes();
60 let mut sum: u32 = 0;
61 for (i, &b) in bytes.iter().enumerate() {
62 let digit = if i == 9 && (b == b'X' || b == b'x') {
63 10
64 } else if b.is_ascii_digit() {
65 (b - b'0') as u32
66 } else {
67 return Err(Error::InvalidInput(format!(
68 "invalid ISBN-10 character '{}'",
69 b as char
70 )));
71 };
72 sum += digit * (10 - i as u32);
73 }
74 if !sum.is_multiple_of(11) {
75 return Err(Error::InvalidInput(format!(
76 "invalid ISBN-10 check digit: {s}"
77 )));
78 }
79 Ok(())
80}
81
82fn validate_isbn13(s: &str) -> Result<()> {
83 if !s.starts_with("978") && !s.starts_with("979") {
84 return Err(Error::InvalidInput(format!(
85 "ISBN-13 must start with 978 or 979: {s}"
86 )));
87 }
88 let bytes = s.as_bytes();
89 let mut sum: u32 = 0;
90 for (i, &b) in bytes.iter().enumerate() {
91 if !b.is_ascii_digit() {
92 return Err(Error::InvalidInput(format!(
93 "invalid ISBN-13 character '{}' at position {i}",
94 b as char
95 )));
96 }
97 let digit = (b - b'0') as u32;
98 sum += digit * if i % 2 == 0 { 1 } else { 3 };
99 }
100 if !sum.is_multiple_of(10) {
101 return Err(Error::InvalidInput(format!(
102 "invalid ISBN-13 check digit: {s}"
103 )));
104 }
105 Ok(())
106}
107
108pub fn validate_subject_slug(slug: &str) -> Result<()> {
111 if slug.is_empty() {
112 return Err(Error::InvalidInput("subject slug must not be empty".into()));
113 }
114 if slug.len() > 200 {
115 return Err(Error::InvalidInput(format!(
116 "subject slug must be ≤ 200 chars, got {}",
117 slug.len()
118 )));
119 }
120 if !slug
121 .chars()
122 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
123 {
124 return Err(Error::InvalidInput(format!(
125 "subject slug must match [a-z0-9_]+: {slug}"
126 )));
127 }
128 Ok(())
129}
130
131pub fn validate_username(username: &str) -> Result<()> {
134 if username.is_empty() {
135 return Err(Error::InvalidInput("username must not be empty".into()));
136 }
137 if username.len() > 64 {
138 return Err(Error::InvalidInput(format!(
139 "username must be ≤ 64 chars, got {}",
140 username.len()
141 )));
142 }
143 if !username
144 .chars()
145 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
146 {
147 return Err(Error::InvalidInput(format!(
148 "username must match [a-zA-Z0-9_-]+: {username}"
149 )));
150 }
151 Ok(())
152}
153
154pub fn validate_list_id(id: &str) -> Result<()> {
157 if id.is_empty() {
158 return Err(Error::InvalidInput("list ID must not be empty".into()));
159 }
160 if !id
161 .chars()
162 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
163 {
164 return Err(Error::InvalidInput(format!(
165 "list ID must match [a-zA-Z0-9_-]+: {id}"
166 )));
167 }
168 Ok(())
169}
170
171pub fn validate_search_query(q: &str) -> Result<()> {
174 if q.is_empty() {
175 return Err(Error::InvalidInput(
176 "search query must not be empty".into(),
177 ));
178 }
179 if q.len() > 1000 {
180 return Err(Error::InvalidInput(format!(
181 "search query must be ≤ 1000 chars, got {}",
182 q.len()
183 )));
184 }
185 Ok(())
186}
187
188pub fn validate_limit(limit: u32) -> Result<()> {
191 if limit == 0 || limit > 1000 {
192 return Err(Error::InvalidInput(format!(
193 "limit must be 1–1000, got {limit}"
194 )));
195 }
196 Ok(())
197}
198
199const VALID_BIBKEY_PREFIXES: &[&str] = &["ISBN:", "OCLC:", "LCCN:", "OLID:", "ID:"];
204
205pub fn validate_bibkey(key: &str) -> Result<()> {
206 if key.is_empty() {
207 return Err(Error::InvalidInput("bibkey must not be empty".into()));
208 }
209 if !VALID_BIBKEY_PREFIXES
210 .iter()
211 .any(|prefix| key.starts_with(prefix))
212 {
213 return Err(Error::InvalidInput(format!(
214 "bibkey must start with one of {:?}: {key}",
215 VALID_BIBKEY_PREFIXES
216 )));
217 }
218 let value = key.split_once(':').map(|x| x.1).unwrap_or("");
219 if value.is_empty() {
220 return Err(Error::InvalidInput(format!(
221 "bibkey value after ':' must not be empty: {key}"
222 )));
223 }
224 Ok(())
225}
226
227pub fn validate_bibkeys(keys: &[String]) -> Result<()> {
228 if keys.is_empty() {
229 return Err(Error::InvalidInput(
230 "bibkeys list must not be empty".into(),
231 ));
232 }
233 for key in keys {
234 validate_bibkey(key)?;
235 }
236 Ok(())
237}
238
239pub fn validate_date(date: &str) -> Result<()> {
243 let parts: Vec<&str> = date.split('-').collect();
244 if parts.len() != 3 {
245 return Err(Error::InvalidInput(format!(
246 "date must be YYYY-MM-DD, got: {date}"
247 )));
248 }
249 let year: u32 = parts[0]
250 .parse()
251 .map_err(|_| Error::InvalidInput(format!("invalid year in date: {date}")))?;
252 let month: u32 = parts[1]
253 .parse()
254 .map_err(|_| Error::InvalidInput(format!("invalid month in date: {date}")))?;
255 let day: u32 = parts[2]
256 .parse()
257 .map_err(|_| Error::InvalidInput(format!("invalid day in date: {date}")))?;
258
259 if year < 1 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
260 return Err(Error::InvalidInput(format!(
261 "date out of range: {date}"
262 )));
263 }
264 Ok(())
265}
266
267pub fn validate_contact_email(email: &str) -> Result<()> {
270 if email.is_empty() {
271 return Err(Error::InvalidInput(
272 "contact email must not be empty".into(),
273 ));
274 }
275 if !email.contains('@') || email.starts_with('@') || email.ends_with('@') {
276 return Err(Error::InvalidInput(format!(
277 "contact email does not look valid: {email}"
278 )));
279 }
280 Ok(())
281}
282
283#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
291 fn work_id_valid() {
292 assert!(validate_work_id("OL45804W").is_ok());
293 assert!(validate_work_id("OL1W").is_ok());
294 }
295
296 #[test]
297 fn work_id_wrong_suffix() {
298 assert!(validate_work_id("OL45804M").is_err());
299 assert!(validate_work_id("OL45804A").is_err());
300 }
301
302 #[test]
303 fn work_id_no_prefix() {
304 assert!(validate_work_id("45804W").is_err());
305 }
306
307 #[test]
308 fn work_id_non_digit_middle() {
309 assert!(validate_work_id("OLabcW").is_err());
310 }
311
312 #[test]
313 fn edition_id_valid() {
314 assert!(validate_edition_id("OL7353617M").is_ok());
315 }
316
317 #[test]
318 fn author_id_valid() {
319 assert!(validate_author_id("OL23919A").is_ok());
320 }
321
322 #[test]
325 fn isbn10_valid() {
326 assert!(validate_isbn("0306406152").is_ok());
328 assert!(validate_isbn("080442957X").is_ok());
330 }
331
332 #[test]
333 fn isbn10_bad_check_digit() {
334 assert!(validate_isbn("0306406151").is_err());
335 }
336
337 #[test]
340 fn isbn13_valid() {
341 assert!(validate_isbn("9780306406157").is_ok());
342 }
343
344 #[test]
345 fn isbn13_bad_prefix() {
346 assert!(validate_isbn("1230306406157").is_err());
347 }
348
349 #[test]
350 fn isbn13_bad_check_digit() {
351 assert!(validate_isbn("9780306406158").is_err());
352 }
353
354 #[test]
357 fn subject_slug_valid() {
358 assert!(validate_subject_slug("love").is_ok());
359 assert!(validate_subject_slug("science_fiction").is_ok());
360 assert!(validate_subject_slug("history2024").is_ok());
361 }
362
363 #[test]
364 fn subject_slug_uppercase_rejected() {
365 assert!(validate_subject_slug("Love").is_err());
366 }
367
368 #[test]
369 fn subject_slug_empty_rejected() {
370 assert!(validate_subject_slug("").is_err());
371 }
372
373 #[test]
376 fn username_valid() {
377 assert!(validate_username("alice").is_ok());
378 assert!(validate_username("alice_123-ok").is_ok());
379 }
380
381 #[test]
382 fn username_empty_rejected() {
383 assert!(validate_username("").is_err());
384 }
385
386 #[test]
387 fn username_too_long_rejected() {
388 let long = "a".repeat(65);
389 assert!(validate_username(&long).is_err());
390 }
391
392 #[test]
395 fn limit_valid() {
396 assert!(validate_limit(1).is_ok());
397 assert!(validate_limit(1000).is_ok());
398 }
399
400 #[test]
401 fn limit_zero_rejected() {
402 assert!(validate_limit(0).is_err());
403 }
404
405 #[test]
406 fn limit_over_max_rejected() {
407 assert!(validate_limit(1001).is_err());
408 }
409
410 #[test]
413 fn bibkey_valid_prefixes() {
414 assert!(validate_bibkey("ISBN:0306406152").is_ok());
415 assert!(validate_bibkey("OCLC:45883427").is_ok());
416 assert!(validate_bibkey("LCCN:2004046975").is_ok());
417 assert!(validate_bibkey("OLID:OL7408846M").is_ok());
418 assert!(validate_bibkey("ID:5428012").is_ok());
419 }
420
421 #[test]
422 fn bibkey_unknown_prefix_rejected() {
423 assert!(validate_bibkey("ASIN:B000FC1234").is_err());
424 }
425
426 #[test]
427 fn bibkeys_empty_list_rejected() {
428 assert!(validate_bibkeys(&[]).is_err());
429 }
430
431 #[test]
434 fn date_valid() {
435 assert!(validate_date("2024-06-15").is_ok());
436 }
437
438 #[test]
439 fn date_invalid_format() {
440 assert!(validate_date("20240615").is_err());
441 assert!(validate_date("2024/06/15").is_err());
442 }
443
444 #[test]
447 fn email_valid() {
448 assert!(validate_contact_email("me@example.com").is_ok());
449 }
450
451 #[test]
452 fn email_no_at_rejected() {
453 assert!(validate_contact_email("notanemail").is_err());
454 }
455}