Skip to main content

open_library_api_rs/
validation.rs

1// v0.0.1
2use crate::error::{Error, Result};
3
4const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024; // 10 MB
5
6pub(crate) fn max_response_bytes() -> u64 {
7    MAX_RESPONSE_BYTES
8}
9
10// ── OLID helpers ──────────────────────────────────────────────────────────────
11
12fn 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
44// ── ISBN ──────────────────────────────────────────────────────────────────────
45
46pub 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
108// ── Subject slug ──────────────────────────────────────────────────────────────
109
110pub 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
131// ── Username ──────────────────────────────────────────────────────────────────
132
133pub 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
154// ── List ID ───────────────────────────────────────────────────────────────────
155
156pub 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
171// ── Search query ──────────────────────────────────────────────────────────────
172
173pub 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
188// ── Pagination ────────────────────────────────────────────────────────────────
189
190pub 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
199// offset ≥ 0 is guaranteed by u32/usize type; no runtime check needed.
200
201// ── Bibkey ────────────────────────────────────────────────────────────────────
202
203const 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
239// ── Date ──────────────────────────────────────────────────────────────────────
240
241/// Validates a date string in `YYYY-MM-DD` format.
242pub 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
267// ── Contact email (User-Agent only) ──────────────────────────────────────────
268
269pub 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// ─────────────────────────────────────────────────────────────────────────────
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    // ── Work / Edition / Author OLIDs ─────────────────────────────────────────
289
290    #[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    // ── ISBN-10 ───────────────────────────────────────────────────────────────
323
324    #[test]
325    fn isbn10_valid() {
326        // "0-306-40615-2" → check digit valid
327        assert!(validate_isbn("0306406152").is_ok());
328        // With X check digit (Knuth TAOCP vol.3)
329        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    // ── ISBN-13 ───────────────────────────────────────────────────────────────
338
339    #[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    // ── Subject slug ──────────────────────────────────────────────────────────
355
356    #[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    // ── Username ──────────────────────────────────────────────────────────────
374
375    #[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    // ── Limit ─────────────────────────────────────────────────────────────────
393
394    #[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    // ── Bibkeys ───────────────────────────────────────────────────────────────
411
412    #[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    // ── Date ──────────────────────────────────────────────────────────────────
432
433    #[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    // ── Email ─────────────────────────────────────────────────────────────────
445
446    #[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}