Skip to content

Commit 1018f07

Browse files
authored
feat(smith): Implement builder for arbitrary responses (#981)
Implements a response builder in `apollo-smith` that takes in bytes as `arbitrary::Unstructured` and outputs a `serde_json_bytes::Value` matching a given `ExecutableDocument`. This can be used with fuzzing for logic that handles GraphQL responses as well as automatic subgraph mocking in test frameworks.
1 parent 5e62d06 commit 1018f07

File tree

6 files changed

+308
-1
lines changed

6 files changed

+308
-1
lines changed

crates/apollo-smith/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ apollo-parser = { path = "../apollo-parser", version = "0.8.0" }
2727
arbitrary = { version = "1.3.0", features = ["derive"] }
2828
indexmap = "2.0.0"
2929
once_cell = "1.9.0"
30+
serde_json_bytes = "0.2.5"
3031
thiserror = "2.0.0"
3132

3233
[dev-dependencies]

crates/apollo-smith/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
</div>
1919

2020
## About
21+
2122
The goal of `apollo-smith` is to generate valid GraphQL documents by sampling
2223
from all available possibilities of [GraphQL grammar].
2324

@@ -113,10 +114,40 @@ pub fn generate_valid_operation(input: &[u8]) -> Result<String> {
113114
}
114115
```
115116

117+
## Generating responses using `apollo-smith` with `apollo-compiler`
118+
119+
If you have a GraphQL operation in the form of an `ExecutableDocument` and its
120+
accompanying `Schema`, you can generate a response matching the shape of the
121+
operation with `apollo_smith::ResponseBuilder`.
122+
123+
```rust
124+
use apollo_compiler::validation::Valid;
125+
use apollo_compiler::ExecutableDocument;
126+
use apollo_compiler::Schema;
127+
use apollo_smith::ResponseBuilder;
128+
use arbitrary::Result;
129+
use arbitrary::Unstructured;
130+
use rand::Rng;
131+
use serde_json_bytes::Value;
132+
133+
pub fn generate_valid_response(
134+
doc: &Valid<ExecutableDocument>,
135+
schema: &Valid<Schema>,
136+
) -> Result<Value> {
137+
let mut buf = [0u8; 2048];
138+
rand::rng().fill(&mut buf);
139+
let mut u = Unstructured::new(&buf);
140+
141+
ResponseBuilder::new(&mut u, doc, schema).build()
142+
}
143+
```
144+
116145
## Limitations
146+
117147
- Recursive object type not yet supported (example : `myType { inner: myType }`)
118148

119149
## License
150+
120151
Licensed under either of
121152

122153
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <https://www.apache.org/licenses/LICENSE-2.0>)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use apollo_compiler::validation::Valid;
2+
use apollo_compiler::ExecutableDocument;
3+
use apollo_compiler::Schema;
4+
use apollo_smith::ResponseBuilder;
5+
use arbitrary::Result;
6+
use arbitrary::Unstructured;
7+
use rand::Rng;
8+
use serde_json_bytes::Value;
9+
use std::fs;
10+
11+
pub fn generate_valid_response(
12+
doc: &Valid<ExecutableDocument>,
13+
schema: &Valid<Schema>,
14+
) -> Result<Value> {
15+
let mut buf = [0u8; 2048];
16+
rand::rng().fill(&mut buf);
17+
let mut u = Unstructured::new(&buf);
18+
19+
ResponseBuilder::new(&mut u, doc, schema)
20+
.with_min_list_size(2)
21+
.with_max_list_size(10)
22+
.build()
23+
}
24+
25+
fn main() -> Result<(), Box<dyn std::error::Error>> {
26+
let mut args = std::env::args().skip(1);
27+
let Some(schema_path) = args.next() else {
28+
return Err("Provide a schema path".into());
29+
};
30+
let schema = fs::read_to_string(schema_path.clone())
31+
.map_err(|e| format!("Failed to read schema file: {e}"))?;
32+
let schema = Schema::parse_and_validate(&schema, &schema_path)
33+
.map_err(|e| format!("Failed to parse schema: {e}"))?;
34+
35+
let Some(doc_path) = args.next() else {
36+
return Err("Provide a document path".into());
37+
};
38+
let doc = fs::read_to_string(doc_path.clone())
39+
.map_err(|e| format!("Failed to read document file: {e}"))?;
40+
let doc = ExecutableDocument::parse_and_validate(&schema, &doc, &doc_path)
41+
.map_err(|e| format!("Failed to parse document: {e}"))?;
42+
43+
let response = generate_valid_response(&doc, &schema)?;
44+
println!("Generated response: {response}");
45+
Ok(())
46+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
query {
2+
dog {
3+
name
4+
nickname
5+
barkVolume
6+
owner {
7+
name
8+
}
9+
}
10+
}

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(crate) mod response;
1617
pub(crate) mod scalar;
1718
pub(crate) mod schema;
1819
pub(crate) mod selection_set;
@@ -22,7 +23,6 @@ pub(crate) mod ty;
2223
pub(crate) mod union;
2324
pub(crate) mod variable;
2425

25-
use arbitrary::Unstructured;
2626
use indexmap::IndexMap;
2727
use std::fmt::Debug;
2828

@@ -39,6 +39,7 @@ pub enum FromError {
3939
}
4040

4141
pub use arbitrary::Result;
42+
pub use arbitrary::Unstructured;
4243
use argument::Argument;
4344
pub use directive::DirectiveDef;
4445
pub use document::Document;
@@ -50,8 +51,11 @@ pub use interface::InterfaceTypeDef;
5051
use name::Name;
5152
pub use object::ObjectTypeDef;
5253
pub use operation::OperationDef;
54+
pub use response::Generator;
55+
pub use response::ResponseBuilder;
5356
pub use scalar::ScalarTypeDef;
5457
pub use schema::SchemaDef;
58+
pub use serde_json_bytes::Value;
5559
use ty::Ty;
5660
pub use union::UnionTypeDef;
5761

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
use apollo_compiler::executable::Selection;
2+
use apollo_compiler::executable::SelectionSet;
3+
use apollo_compiler::schema::ExtendedType;
4+
use apollo_compiler::validation::Valid;
5+
use apollo_compiler::ExecutableDocument;
6+
use apollo_compiler::Name;
7+
use apollo_compiler::Schema;
8+
use arbitrary::Result;
9+
use arbitrary::Unstructured;
10+
use serde_json_bytes::json;
11+
use serde_json_bytes::serde_json::Number;
12+
use serde_json_bytes::Map;
13+
use serde_json_bytes::Value;
14+
use std::collections::HashMap;
15+
16+
const TYPENAME: &str = "__typename";
17+
18+
pub type Generator = Box<dyn Fn(&mut Unstructured) -> Result<Value>>;
19+
20+
/// Builds a GraphQL response which matches the shape of a given executable GraphQL document.
21+
///
22+
/// Documentation of the response format can be found in the [GraphQL spec](https://spec.graphql.org/draft/#sec-Execution-Result).
23+
pub struct ResponseBuilder<'a, 'doc, 'schema> {
24+
u: &'a mut Unstructured<'a>,
25+
doc: &'doc Valid<ExecutableDocument>,
26+
schema: &'schema Valid<Schema>,
27+
custom_scalar_generators: HashMap<Name, Generator>,
28+
min_list_size: usize,
29+
max_list_size: usize,
30+
null_ratio: Option<(u8, u8)>,
31+
operation_name: Option<&'doc str>,
32+
}
33+
34+
impl<'a, 'doc, 'schema> ResponseBuilder<'a, 'doc, 'schema> {
35+
pub fn new(
36+
u: &'a mut Unstructured<'a>,
37+
doc: &'doc Valid<ExecutableDocument>,
38+
schema: &'schema Valid<Schema>,
39+
) -> Self {
40+
Self {
41+
u,
42+
doc,
43+
schema,
44+
custom_scalar_generators: HashMap::new(),
45+
min_list_size: 0,
46+
max_list_size: 5,
47+
null_ratio: None,
48+
operation_name: None,
49+
}
50+
}
51+
52+
/// Register a generator function for generating custom scalar values.
53+
pub fn with_custom_scalar(mut self, scalar_name: Name, generator: Generator) -> Self {
54+
self.custom_scalar_generators.insert(scalar_name, generator);
55+
self
56+
}
57+
58+
/// Set the minimum number of items per list field. If unset, defaults to 0.
59+
pub fn with_min_list_size(mut self, min_size: usize) -> Self {
60+
self.min_list_size = min_size;
61+
self
62+
}
63+
64+
/// Set the maximum number of items per list field. If unset, defaults to 5.
65+
pub fn with_max_list_size(mut self, max_size: usize) -> Self {
66+
self.max_list_size = max_size;
67+
self
68+
}
69+
70+
/// Set the frequency of null values for nullable fields. If unset, fields will never be null.
71+
pub fn with_null_ratio(mut self, numerator: u8, denominator: u8) -> Self {
72+
self.null_ratio = Some((numerator, denominator));
73+
self
74+
}
75+
76+
/// Set the operation name to generate a response for. If unset, uses the anonymous operation.
77+
/// If the operation does not exist, returns a response with `data: null`.
78+
pub fn with_operation_name(mut self, operation_name: Option<&'doc str>) -> Self {
79+
self.operation_name = operation_name;
80+
self
81+
}
82+
83+
/// Builds a `Value` matching the shape of `self.doc`
84+
pub fn build(mut self) -> Result<Value> {
85+
if let Ok(operation) = self.doc.operations.get(self.operation_name) {
86+
Ok(json!({ "data": self.arbitrary_selection_set(&operation.selection_set)? }))
87+
} else {
88+
Ok(json!({ "data": null }))
89+
}
90+
}
91+
92+
fn arbitrary_selection_set(&mut self, selection_set: &SelectionSet) -> Result<Value> {
93+
let mut result = Map::new();
94+
95+
for selection in &selection_set.selections {
96+
match selection {
97+
Selection::Field(field) => {
98+
if field.name == TYPENAME {
99+
result.insert(
100+
field.name.to_string(),
101+
Value::String(selection_set.ty.to_string().into()),
102+
);
103+
} else if !field.ty().is_non_null() && self.should_be_null()? {
104+
result.insert(field.name.to_string(), Value::Null);
105+
} else if field.selection_set.is_empty() && !field.ty().is_list() {
106+
result.insert(
107+
field.name.to_string(),
108+
self.arbitrary_leaf_field(field.ty().inner_named_type())?,
109+
);
110+
} else if field.selection_set.is_empty() && field.ty().is_list() {
111+
result.insert(
112+
field.name.to_string(),
113+
self.repeated_arbitrary_leaf_field(field.ty().inner_named_type())?,
114+
);
115+
} else if !field.selection_set.is_empty() && !field.ty().is_list() {
116+
result.insert(
117+
field.name.to_string(),
118+
self.arbitrary_selection_set(&field.selection_set)?,
119+
);
120+
} else {
121+
result.insert(
122+
field.name.to_string(),
123+
self.repeated_arbitrary_selection_set(&field.selection_set)?,
124+
);
125+
}
126+
}
127+
Selection::FragmentSpread(fragment) => {
128+
if let Some(fragment_def) = self.doc.fragments.get(&fragment.fragment_name) {
129+
let value = self.arbitrary_selection_set(&fragment_def.selection_set)?;
130+
if let Some(value_obj) = value.as_object() {
131+
result.extend(value_obj.clone());
132+
}
133+
}
134+
}
135+
Selection::InlineFragment(inline_fragment) => {
136+
let value = self.arbitrary_selection_set(&inline_fragment.selection_set)?;
137+
if let Some(value_obj) = value.as_object() {
138+
result.extend(value_obj.clone());
139+
}
140+
}
141+
}
142+
}
143+
144+
Ok(Value::Object(result))
145+
}
146+
147+
fn repeated_arbitrary_selection_set(&mut self, selection_set: &SelectionSet) -> Result<Value> {
148+
let num_values = self.arbitrary_len()?;
149+
let mut values = Vec::with_capacity(num_values);
150+
for _ in 0..num_values {
151+
values.push(self.arbitrary_selection_set(selection_set)?);
152+
}
153+
Ok(Value::Array(values))
154+
}
155+
156+
fn arbitrary_leaf_field(&mut self, type_name: &Name) -> Result<Value> {
157+
let extended_ty = self.schema.types.get(type_name).unwrap();
158+
match extended_ty {
159+
ExtendedType::Enum(enum_ty) => {
160+
let enum_value = self.u.choose_iter(enum_ty.values.values())?;
161+
Ok(Value::String(enum_value.value.to_string().into()))
162+
}
163+
ExtendedType::Scalar(scalar) => {
164+
if scalar.name == "Boolean" {
165+
let random_bool = self.u.arbitrary::<bool>()?;
166+
Ok(Value::Bool(random_bool))
167+
} else if scalar.name == "Int" || scalar.name == "ID" {
168+
let random_int = self.u.int_in_range(0..=100)?;
169+
Ok(Value::Number(random_int.into()))
170+
} else if scalar.name == "Float" {
171+
let random_float = self.u.arbitrary::<f64>()?;
172+
Ok(Value::Number(Number::from_f64(random_float).unwrap()))
173+
} else if scalar.name == "String" {
174+
let random_string = self.u.arbitrary::<String>()?;
175+
Ok(Value::String(random_string.into()))
176+
} else if let Some(custom_generator) =
177+
self.custom_scalar_generators.get(&scalar.name)
178+
{
179+
let random_value = custom_generator(self.u)?;
180+
Ok(random_value)
181+
} else {
182+
// Likely a custom scalar which hasn't had a generator registered
183+
let random_string = self.u.arbitrary::<String>()?;
184+
Ok(Value::String(random_string.into()))
185+
}
186+
}
187+
_ => unreachable!(
188+
"We are in a field with an empty selection set, so it must be a scalar or enum type"
189+
),
190+
}
191+
}
192+
193+
fn repeated_arbitrary_leaf_field(&mut self, type_name: &Name) -> Result<Value> {
194+
let num_values = self.arbitrary_len()?;
195+
let mut values = Vec::with_capacity(num_values);
196+
for _ in 0..num_values {
197+
values.push(self.arbitrary_leaf_field(type_name)?);
198+
}
199+
Ok(Value::Array(values))
200+
}
201+
202+
fn arbitrary_len(&mut self) -> Result<usize> {
203+
// Ideally, we would use `u.arbitrary_len()` to ensure we can generate enough values from
204+
// the remaining bytes, but it needs a type `T: Arbitrary` which `Value` does not implement.
205+
self.u.int_in_range(self.min_list_size..=self.max_list_size)
206+
}
207+
208+
fn should_be_null(&mut self) -> Result<bool> {
209+
if let Some((numerator, denominator)) = self.null_ratio {
210+
self.u.ratio(numerator, denominator)
211+
} else {
212+
Ok(false)
213+
}
214+
}
215+
}

0 commit comments

Comments
 (0)