Skip to content

Commit b5119cd

Browse files
committed
feat(smith) Backport configurable response generation
When we built the Rust subgraph mock server, we started with apollo-smith's response generation code as our foundation. Over time, we've added more configuration flexibility and GraphQL spec-compliant behavior to that code, diverging from this source. This change backports those improvements and fixes and exposes configuration options that will allow the subgraph mock to just take a dependency on this code rather than remaining a fork of it.
1 parent e540e57 commit b5119cd

File tree

6 files changed

+782
-109
lines changed

6 files changed

+782
-109
lines changed

crates/apollo-smith/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "apollo-smith"
3-
version = "0.15.2" # When bumping, also update README.md
3+
version = "0.16.0" # When bumping, also update README.md
44
edition = "2021"
55
authors = ["Benjamin Coenen <benjamin.coenen@apollographql.com>"]
66
license = "MIT OR Apache-2.0"
@@ -27,9 +27,9 @@ apollo-parser = { path = "../apollo-parser", version = "0.8.0" }
2727
arbitrary = { version = "1.4.0", features = ["derive"] }
2828
indexmap = "2.0.0"
2929
once_cell = "1.9.0"
30+
rand = "0.10.0"
3031
serde_json_bytes = "0.2.5"
3132
thiserror = "2.0.0"
3233

3334
[dev-dependencies]
3435
expect-test = "1.4"
35-
rand = "0.10.0"

crates/apollo-smith/README.md

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ and add `apollo-smith` to your Cargo.toml:
5050
## fuzz/Cargo.toml
5151

5252
[dependencies]
53-
apollo-smith = "0.15.2"
53+
apollo-smith = "0.16.0"
5454
```
5555

5656
It can then be used in a `fuzz_target` along with the [`arbitrary`] crate,
@@ -120,20 +120,25 @@ If you have a GraphQL operation in the form of an `ExecutableDocument` and its
120120
accompanying `Schema`, you can generate a response matching the shape of the
121121
operation with `apollo_smith::ResponseBuilder`.
122122

123+
`ResponseBuilder` is generic over its randomness source via the `RandomProvider`
124+
trait. This allows it to be used with `arbitrary::Unstructured` for fuzz testing,
125+
with `RandProvider` for standard random generation, or with any custom implementation.
126+
127+
### Using `Unstructured` (for fuzz testing)
128+
123129
```rust
124130
use apollo_compiler::validation::Valid;
125131
use apollo_compiler::ExecutableDocument;
126132
use apollo_compiler::Schema;
127-
use apollo_smith::ResponseBuilder;
128-
use arbitrary::Result;
133+
use apollo_smith::{ResponseBuilder, ResponseError};
129134
use arbitrary::Unstructured;
130135
use rand::RngExt as _;
131136
use serde_json_bytes::Value;
132137

133138
pub fn generate_valid_response(
134139
doc: &Valid<ExecutableDocument>,
135140
schema: &Valid<Schema>,
136-
) -> Result<Value> {
141+
) -> Result<Value, ResponseError> {
137142
let mut buf = [0u8; 2048];
138143
rand::rng().fill(&mut buf);
139144
let mut u = Unstructured::new(&buf);
@@ -142,6 +147,48 @@ pub fn generate_valid_response(
142147
}
143148
```
144149

150+
### Using `RandProvider`
151+
152+
Use `RandProvider` to wrap any `rand::Rng`:
153+
154+
```rust,ignore
155+
use apollo_smith::{RandProvider, ResponseBuilder};
156+
157+
let mut rng = RandProvider(rand::rng());
158+
let response = ResponseBuilder::new(&mut rng, &doc, &schema)
159+
.with_min_list_size(1)
160+
.with_max_list_size(5)
161+
.with_null_ratio(1, 4)
162+
.build()?;
163+
```
164+
165+
### Configuring scalar generation
166+
167+
Use `with_scalar_config` to override how values are generated for specific scalar types:
168+
169+
```rust,ignore
170+
use apollo_smith::{ResponseBuilder, ScalarConfig};
171+
use apollo_compiler::Name;
172+
173+
let response = ResponseBuilder::new(&mut rng, &doc, &schema)
174+
.with_scalar_config(
175+
Name::new_unchecked("ID".into()),
176+
ScalarConfig::String { min_len: 8, max_len: 8 },
177+
)
178+
.build()?;
179+
```
180+
181+
### Federation support
182+
183+
For Apollo Federation subgraphs, you can use `override_sdl` to provide correct `_service { sdl }`
184+
responses instead of including the full schema (and all the generated federation types) in the response:
185+
186+
```rust,ignore
187+
let response = ResponseBuilder::new(&mut rng, &doc, &schema)
188+
.override_sdl(&original_sdl_string)
189+
.build()?;
190+
```
191+
145192
## Limitations
146193

147194
- Recursive object type not yet supported (example : `myType { inner: myType }`)

crates/apollo-smith/examples/generate_response.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use apollo_compiler::validation::Valid;
22
use apollo_compiler::ExecutableDocument;
33
use apollo_compiler::Schema;
44
use apollo_smith::ResponseBuilder;
5-
use arbitrary::Result;
5+
use apollo_smith::ResponseError;
66
use arbitrary::Unstructured;
77
use rand::RngExt as _;
88
use serde_json_bytes::Value;
@@ -11,7 +11,7 @@ use std::fs;
1111
pub fn generate_valid_response(
1212
doc: &Valid<ExecutableDocument>,
1313
schema: &Valid<Schema>,
14-
) -> Result<Value> {
14+
) -> Result<Value, ResponseError> {
1515
let mut buf = [0u8; 2048];
1616
rand::rng().fill(&mut buf);
1717
let mut u = Unstructured::new(&buf);

crates/apollo-smith/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub(crate) mod interface;
1313
pub(crate) mod name;
1414
pub(crate) mod object;
1515
pub(crate) mod operation;
16+
pub mod random;
1617
pub(crate) mod response;
1718
pub(crate) mod scalar;
1819
pub(crate) mod schema;
@@ -51,7 +52,10 @@ pub use interface::InterfaceTypeDef;
5152
use name::Name;
5253
pub use object::ObjectTypeDef;
5354
pub use operation::OperationDef;
54-
pub use response::Generator;
55+
pub use random::RandProvider;
56+
pub use random::RandomProvider;
57+
pub use random::ResponseError;
58+
pub use random::ScalarConfig;
5559
pub use response::ResponseBuilder;
5660
pub use scalar::ScalarTypeDef;
5761
pub use schema::SchemaDef;

crates/apollo-smith/src/random.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

Comments
 (0)