|
| 1 | +use apollo_compiler::Name; |
| 2 | +use arbitrary::Unstructured; |
| 3 | +use rand::RngExt; |
| 4 | +use serde_json_bytes::serde_json::Number; |
| 5 | +use serde_json_bytes::Value; |
| 6 | +use std::collections::HashMap; |
| 7 | + |
| 8 | +/// Error type for response generation. |
| 9 | +#[derive(Debug, thiserror::Error)] |
| 10 | +pub enum ResponseError { |
| 11 | + /// The randomness source was exhausted or produced invalid data. |
| 12 | + #[error("randomness source exhausted or produced invalid data")] |
| 13 | + Exhausted, |
| 14 | + /// The randomness source produced data that could not be converted to the expected format. |
| 15 | + #[error("invalid format: {0}")] |
| 16 | + InvalidFormat(String), |
| 17 | +} |
| 18 | + |
| 19 | +/// Abstraction over a source of randomness for response generation. |
| 20 | +/// |
| 21 | +/// Implementations are provided for [`Unstructured`] (for fuzz testing) and |
| 22 | +/// for any type implementing [`rand::Rng`] via the [`RandProvider`] newtype. |
| 23 | +pub trait RandomProvider { |
| 24 | + /// Generate a random boolean. |
| 25 | + fn gen_bool(&mut self) -> Result<bool, ResponseError>; |
| 26 | + |
| 27 | + /// Generate a random `i32` within the inclusive range `[min, max]`. |
| 28 | + fn gen_i32_range(&mut self, min: i32, max: i32) -> Result<i32, ResponseError>; |
| 29 | + |
| 30 | + /// Generate a random `usize` within the inclusive range `[min, max]`. |
| 31 | + fn gen_usize_range(&mut self, min: usize, max: usize) -> Result<usize, ResponseError>; |
| 32 | + |
| 33 | + /// Generate a random `f64` within the inclusive range `[min, max]`. |
| 34 | + fn gen_f64_range(&mut self, min: f64, max: f64) -> Result<f64, ResponseError>; |
| 35 | + |
| 36 | + /// Generate a random alphanumeric character (`[0-9a-zA-Z]`). |
| 37 | + fn gen_alphanumeric_char(&mut self) -> Result<char, ResponseError>; |
| 38 | + |
| 39 | + /// Choose a random index in `0..len`. Returns an error if `len == 0`. |
| 40 | + fn choose_index(&mut self, len: usize) -> Result<usize, ResponseError>; |
| 41 | + |
| 42 | + /// Return `true` with probability `numerator / denominator`. |
| 43 | + fn ratio(&mut self, numerator: u32, denominator: u32) -> Result<bool, ResponseError>; |
| 44 | +} |
| 45 | + |
| 46 | +impl RandomProvider for Unstructured<'_> { |
| 47 | + fn gen_bool(&mut self) -> Result<bool, ResponseError> { |
| 48 | + self.arbitrary::<bool>() |
| 49 | + .map_err(|_| ResponseError::Exhausted) |
| 50 | + } |
| 51 | + |
| 52 | + fn gen_i32_range(&mut self, min: i32, max: i32) -> Result<i32, ResponseError> { |
| 53 | + self.int_in_range(min..=max) |
| 54 | + .map_err(|_| ResponseError::Exhausted) |
| 55 | + } |
| 56 | + |
| 57 | + fn gen_usize_range(&mut self, min: usize, max: usize) -> Result<usize, ResponseError> { |
| 58 | + self.int_in_range(min..=max) |
| 59 | + .map_err(|_| ResponseError::Exhausted) |
| 60 | + } |
| 61 | + |
| 62 | + fn gen_f64_range(&mut self, min: f64, max: f64) -> Result<f64, ResponseError> { |
| 63 | + // Unstructured doesn't support float ranges, so we generate a raw f64 |
| 64 | + // and map it into [min, max]. |
| 65 | + let raw: u32 = self.arbitrary().map_err(|_| ResponseError::Exhausted)?; |
| 66 | + let fraction = (raw as f64) / (u32::MAX as f64); // [0.0, 1.0] |
| 67 | + Ok(min + fraction * (max - min)) |
| 68 | + } |
| 69 | + |
| 70 | + fn gen_alphanumeric_char(&mut self) -> Result<char, ResponseError> { |
| 71 | + let idx: u8 = self |
| 72 | + .int_in_range(0..=61) |
| 73 | + .map_err(|_| ResponseError::Exhausted)?; |
| 74 | + Ok(match idx { |
| 75 | + 0..=9 => (b'0' + idx) as char, |
| 76 | + 10..=35 => (b'a' + idx - 10) as char, |
| 77 | + _ => (b'A' + idx - 36) as char, |
| 78 | + }) |
| 79 | + } |
| 80 | + |
| 81 | + fn choose_index(&mut self, len: usize) -> Result<usize, ResponseError> { |
| 82 | + if len == 0 { |
| 83 | + return Err(ResponseError::InvalidFormat( |
| 84 | + "cannot choose from empty collection".into(), |
| 85 | + )); |
| 86 | + } |
| 87 | + self.int_in_range(0..=(len - 1)) |
| 88 | + .map_err(|_| ResponseError::Exhausted) |
| 89 | + } |
| 90 | + |
| 91 | + fn ratio(&mut self, numerator: u32, denominator: u32) -> Result<bool, ResponseError> { |
| 92 | + // Unstructured::ratio takes u8, so we scale down if needed. |
| 93 | + // For ratios that fit in u8, use directly; otherwise approximate. |
| 94 | + let (n, d) = if numerator <= u8::MAX as u32 && denominator <= u8::MAX as u32 { |
| 95 | + (numerator as u8, denominator as u8) |
| 96 | + } else { |
| 97 | + // Scale to fit in u8 |
| 98 | + let scale = denominator.max(1) as f64 / 255.0; |
| 99 | + let n = (numerator as f64 / scale).round() as u8; |
| 100 | + let d = ((denominator as f64 / scale).round() as u8).max(1); |
| 101 | + (n, d) |
| 102 | + }; |
| 103 | + self.ratio(n, d).map_err(|_| ResponseError::Exhausted) |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +/// Newtype wrapper that implements [`RandomProvider`] for any [`rand::Rng`]. |
| 108 | +/// |
| 109 | +/// # Example |
| 110 | +/// |
| 111 | +/// ```ignore |
| 112 | +/// use apollo_smith::RandProvider; |
| 113 | +/// |
| 114 | +/// let mut rng = RandProvider(rand::rng()); |
| 115 | +/// let response = ResponseBuilder::new(&mut rng, &doc, &schema).build()?; |
| 116 | +/// ``` |
| 117 | +pub struct RandProvider<R>(pub R); |
| 118 | + |
| 119 | +impl<R: rand::Rng> RandomProvider for RandProvider<R> { |
| 120 | + fn gen_bool(&mut self) -> Result<bool, ResponseError> { |
| 121 | + Ok(self.0.random_bool(0.5)) |
| 122 | + } |
| 123 | + |
| 124 | + fn gen_i32_range(&mut self, min: i32, max: i32) -> Result<i32, ResponseError> { |
| 125 | + Ok(self.0.random_range(min..=max)) |
| 126 | + } |
| 127 | + |
| 128 | + fn gen_usize_range(&mut self, min: usize, max: usize) -> Result<usize, ResponseError> { |
| 129 | + Ok(self.0.random_range(min..=max)) |
| 130 | + } |
| 131 | + |
| 132 | + fn gen_f64_range(&mut self, min: f64, max: f64) -> Result<f64, ResponseError> { |
| 133 | + Ok(self.0.random_range(min..=max)) |
| 134 | + } |
| 135 | + |
| 136 | + fn gen_alphanumeric_char(&mut self) -> Result<char, ResponseError> { |
| 137 | + Ok(self.0.sample(rand::distr::Alphanumeric) as char) |
| 138 | + } |
| 139 | + |
| 140 | + fn choose_index(&mut self, len: usize) -> Result<usize, ResponseError> { |
| 141 | + if len == 0 { |
| 142 | + return Err(ResponseError::InvalidFormat( |
| 143 | + "cannot choose from empty collection".into(), |
| 144 | + )); |
| 145 | + } |
| 146 | + Ok(self.0.random_range(0..len)) |
| 147 | + } |
| 148 | + |
| 149 | + fn ratio(&mut self, numerator: u32, denominator: u32) -> Result<bool, ResponseError> { |
| 150 | + Ok(self.0.random_ratio(numerator, denominator)) |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +/// Configuration for generating scalar values. |
| 155 | +/// |
| 156 | +/// Each variant describes how to generate a value of a particular type using |
| 157 | +/// a [`RandomProvider`]. Register custom scalar configs via |
| 158 | +/// [`ResponseBuilder::with_scalar_config`][crate::ResponseBuilder::with_scalar_config]. |
| 159 | +#[derive(Debug, Clone)] |
| 160 | +pub enum ScalarConfig { |
| 161 | + /// Generate a random boolean. |
| 162 | + Bool, |
| 163 | + /// Generate a random integer in the given inclusive range. |
| 164 | + Int { min: i32, max: i32 }, |
| 165 | + /// Generate a random float in the given inclusive range. |
| 166 | + Float { min: f64, max: f64 }, |
| 167 | + /// Generate a random alphanumeric string with length in the given inclusive range. |
| 168 | + String { min_len: usize, max_len: usize }, |
| 169 | +} |
| 170 | + |
| 171 | +impl ScalarConfig { |
| 172 | + /// The default configuration used for unknown or custom scalars: an |
| 173 | + /// alphanumeric string of length 1–10. |
| 174 | + pub const DEFAULT: Self = Self::String { |
| 175 | + min_len: 1, |
| 176 | + max_len: 10, |
| 177 | + }; |
| 178 | + |
| 179 | + /// Generate a random value according to this configuration. |
| 180 | + pub fn generate<R: RandomProvider>(&self, rng: &mut R) -> Result<Value, ResponseError> { |
| 181 | + match *self { |
| 182 | + Self::Bool => Ok(Value::Bool(rng.gen_bool()?)), |
| 183 | + Self::Int { min, max } => Ok(Value::Number(rng.gen_i32_range(min, max)?.into())), |
| 184 | + Self::Float { min, max } => { |
| 185 | + let f = rng.gen_f64_range(min, max)?; |
| 186 | + let num = Number::from_f64(f).ok_or_else(|| { |
| 187 | + ResponseError::InvalidFormat("generated non-finite float".into()) |
| 188 | + })?; |
| 189 | + Ok(Value::Number(num)) |
| 190 | + } |
| 191 | + Self::String { min_len, max_len } => { |
| 192 | + let len = rng.gen_usize_range(min_len, max_len)?; |
| 193 | + let s: Result<std::string::String, _> = |
| 194 | + (0..len).map(|_| rng.gen_alphanumeric_char()).collect(); |
| 195 | + Ok(Value::String(s?.into())) |
| 196 | + } |
| 197 | + } |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +/// Returns the default scalar configurations for the built-in GraphQL scalar types. |
| 202 | +pub fn default_scalar_configs() -> HashMap<Name, ScalarConfig> { |
| 203 | + [ |
| 204 | + (Name::new_unchecked("Boolean"), ScalarConfig::Bool), |
| 205 | + ( |
| 206 | + Name::new_unchecked("Int"), |
| 207 | + ScalarConfig::Int { min: 0, max: 100 }, |
| 208 | + ), |
| 209 | + ( |
| 210 | + Name::new_unchecked("ID"), |
| 211 | + ScalarConfig::Int { min: 0, max: 100 }, |
| 212 | + ), |
| 213 | + ( |
| 214 | + Name::new_unchecked("Float"), |
| 215 | + ScalarConfig::Float { |
| 216 | + min: -1.0, |
| 217 | + max: 1.0, |
| 218 | + }, |
| 219 | + ), |
| 220 | + ( |
| 221 | + Name::new_unchecked("String"), |
| 222 | + ScalarConfig::String { |
| 223 | + min_len: 1, |
| 224 | + max_len: 10, |
| 225 | + }, |
| 226 | + ), |
| 227 | + ] |
| 228 | + .into_iter() |
| 229 | + .collect() |
| 230 | +} |
0 commit comments