Skip to content

Commit 06131de

Browse files
authored
Merge pull request #34 from Virtual-Repetitions/enum-params
Add Sentry integration and enum input validation
2 parents 6341b36 + 2035f8c commit 06131de

10 files changed

Lines changed: 767 additions & 102 deletions

File tree

Cargo.lock

Lines changed: 482 additions & 68 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace.package]
2-
version = "0.29.8"
2+
version = "0.29.9"
33
edition = "2024"
44

55
# See https://github.com/mozilla/application-services/blob/main/Cargo.toml for the reasons why we use this structure
@@ -133,6 +133,7 @@ pluralizer = "0.5.0"
133133
tracing-subscriber = "0.3.20"
134134
toml = { version = "0.9.5", features = ["parse"] }
135135
zip = "6.0.0"
136+
sentry = "0.32.0"
136137

137138
# reduce binary size, does not affect stack traces
138139
[profile.dev]

crates/core-subsystem/core-resolver/src/validation/document_validator.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,36 @@ mod tests {
303303
);
304304
}
305305

306+
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
307+
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
308+
async fn enum_in_input_object_variable_is_valid() {
309+
let schema = create_enum_test_schema().await;
310+
311+
let variables = create_variables(
312+
r#"
313+
{
314+
"data": {
315+
"title": "EnumVar",
316+
"priority": "LOW"
317+
}
318+
}"#,
319+
);
320+
321+
let validator = DocumentValidator::new(&schema, None, Some(variables), 10, 10);
322+
323+
let query = r#"
324+
mutation($data: TaskCreationInput!) {
325+
createTask(data: $data) {
326+
id
327+
priority
328+
}
329+
}
330+
"#;
331+
332+
let result = validator.validate(create_query_document(query));
333+
assert!(result.is_ok(), "{result:?}");
334+
}
335+
306336
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
307337
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
308338
async fn invalid_subfield() {
@@ -837,6 +867,44 @@ mod tests {
837867
)
838868
}
839869

870+
async fn create_enum_test_schema() -> Schema {
871+
let test_exo = r#"
872+
@postgres
873+
module LogModule {
874+
@access(true)
875+
type Task {
876+
@pk id: Int = autoIncrement()
877+
title: String
878+
priority: Priority
879+
}
880+
881+
enum Priority {
882+
LOW
883+
MEDIUM
884+
HIGH
885+
}
886+
}
887+
"#;
888+
889+
let postgres_subsystem =
890+
create_postgres_system_from_str(test_exo, "enum-test.exo".to_string()).await;
891+
892+
Schema::new(
893+
postgres_subsystem.graphql.as_ref().unwrap().schema_types(),
894+
postgres_subsystem
895+
.graphql
896+
.as_ref()
897+
.unwrap()
898+
.schema_queries(),
899+
postgres_subsystem
900+
.graphql
901+
.as_ref()
902+
.unwrap()
903+
.schema_mutations(),
904+
Arc::new(None),
905+
)
906+
}
907+
840908
fn create_query_document(query_str: &str) -> ExecutableDocument {
841909
parse_query(query_str).unwrap()
842910
}

crates/core-subsystem/core-resolver/src/validation/operation_validator.rs

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use async_graphql_parser::{
1616
VariableDefinition,
1717
},
1818
};
19-
use async_graphql_value::{ConstValue, Name};
19+
use async_graphql_value::{ConstValue, Name, indexmap::IndexMap};
2020
use serde::de::Error;
2121
use serde_json::{Map, Value};
2222

@@ -226,33 +226,77 @@ impl<'a> OperationValidator<'a> {
226226
}),
227227
},
228228
BaseType::Named(type_name) => {
229-
if let Some(type_definition) = self.schema.get_type_definition(type_name.as_str())
230-
&& let TypeKind::Enum(enum_type) = &type_definition.kind
231-
{
232-
return match value {
233-
Value::String(enum_value) => {
234-
let is_valid = enum_type
235-
.values
236-
.iter()
237-
.any(|value_def| value_def.node.value.node.as_str() == enum_value);
229+
if let Some(type_definition) = self.schema.get_type_definition(type_name.as_str()) {
230+
match &type_definition.kind {
231+
TypeKind::Enum(enum_type) => {
232+
return match value {
233+
Value::String(enum_value) => {
234+
let is_valid = enum_type.values.iter().any(|value_def| {
235+
value_def.node.value.node.as_str() == enum_value
236+
});
238237

239-
if is_valid {
240-
Ok(ConstValue::Enum(Name::new(enum_value)))
241-
} else {
242-
Err(error(format!(
243-
"Invalid enum value '{}' for type '{}'",
244-
enum_value,
245-
type_name.as_str()
246-
)))
247-
}
238+
if is_valid {
239+
Ok(ConstValue::Enum(Name::new(enum_value)))
240+
} else {
241+
Err(error(format!(
242+
"Invalid enum value '{}' for type '{}'",
243+
enum_value,
244+
type_name.as_str()
245+
)))
246+
}
247+
}
248+
Value::Null => Ok(ConstValue::Null),
249+
_ => Err(error(format!(
250+
"Expected enum value for type '{}', got {}",
251+
type_name.as_str(),
252+
value
253+
))),
254+
};
248255
}
249-
Value::Null => Ok(ConstValue::Null),
250-
_ => Err(error(format!(
251-
"Expected enum value for type '{}', got {}",
252-
type_name.as_str(),
253-
value
254-
))),
255-
};
256+
TypeKind::InputObject(input_object_type) => {
257+
return match value {
258+
Value::Object(values) => {
259+
let coerced_values = values
260+
.into_iter()
261+
.map(|(field_name, field_value)| {
262+
let field_definition =
263+
input_object_type.fields.iter().find(|field| {
264+
field.node.name.node.as_str() == field_name
265+
});
266+
267+
let coerced_value = match field_definition {
268+
Some(field_definition) => self
269+
.coerce_variable_value(
270+
&field_definition.node.ty.node,
271+
field_value,
272+
variable_name,
273+
)?,
274+
None => ConstValue::from_json(field_value)
275+
.map_err(|e| {
276+
ValidationError::MalformedVariable(
277+
variable_name.node.as_str().to_string(),
278+
variable_name.pos,
279+
e,
280+
)
281+
})?,
282+
};
283+
284+
Ok((Name::new(field_name), coerced_value))
285+
})
286+
.collect::<Result<IndexMap<_, _>, ValidationError>>()?;
287+
288+
Ok(ConstValue::Object(coerced_values))
289+
}
290+
Value::Null => Ok(ConstValue::Null),
291+
_ => Err(error(format!(
292+
"Expected input object for type '{}', got {}",
293+
type_name.as_str(),
294+
value
295+
))),
296+
};
297+
}
298+
_ => {}
299+
}
256300
}
257301

258302
ConstValue::from_json(value).map_err(|e| {

crates/graphql-router/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ core-resolver = { path = "../core-subsystem/core-resolver" }
1818
introspection-resolver = { path = "../introspection-subsystem/introspection-resolver" }
1919
common = { path = "../common" }
2020
exo-env = { path = "../../libs/exo-env" }
21+
sentry.workspace = true
2122

2223
[dev-dependencies]
2324

crates/graphql-router/src/graphql_router.rs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use core_resolver::introspection::definition::schema::Schema;
2323
use core_resolver::plugin::SubsystemGraphQLResolver;
2424
use core_router::SystemLoadingError;
2525
use http::StatusCode;
26+
use sentry::Level;
2627

2728
use ::tracing::instrument;
2829
use async_graphql_parser::Pos;
@@ -83,6 +84,39 @@ impl GraphQLRouter {
8384
}
8485
}
8586

87+
fn capture_graphql_error(
88+
err: &SystemResolutionError,
89+
request_context: &RequestContext<'_>,
90+
status_code: StatusCode,
91+
) {
92+
if sentry::Hub::current().client().is_none() {
93+
return;
94+
}
95+
96+
let request_head = request_context.get_head();
97+
let message = err.user_error_message();
98+
99+
sentry::with_scope(
100+
|scope| {
101+
scope.set_tag("graphql.path", request_head.get_path());
102+
scope.set_tag("http.method", request_head.get_method().as_str());
103+
scope.set_tag("error.kind", format!("{:?}", err));
104+
scope.set_tag(
105+
"internal_request",
106+
request_context.is_internal().to_string(),
107+
);
108+
scope.set_tag(
109+
"auth_present",
110+
request_context.is_authentication_info_present().to_string(),
111+
);
112+
scope.set_extra("status_code", status_code.as_u16().into());
113+
},
114+
|| {
115+
sentry::capture_message(&message, Level::Error);
116+
},
117+
);
118+
}
119+
86120
#[async_trait]
87121
impl<'a> Router<RequestContext<'a>> for GraphQLRouter {
88122
/// Resolves an incoming query, returning a response stream containing JSON and a set
@@ -125,13 +159,20 @@ impl<'a> Router<RequestContext<'a>> for GraphQLRouter {
125159
)
126160
.await;
127161

128-
if let Err(SystemResolutionError::RequestError(e)) = response {
129-
tracing::error!("Error while resolving request: {:?}", e);
130-
return Some(ResponsePayload {
131-
body: ResponseBody::None,
132-
headers: Headers::new(),
133-
status_code: StatusCode::BAD_REQUEST,
134-
});
162+
match &response {
163+
Err(err @ SystemResolutionError::RequestError(e)) => {
164+
tracing::error!("Error while resolving request: {:?}", e);
165+
capture_graphql_error(err, request_context, StatusCode::BAD_REQUEST);
166+
return Some(ResponsePayload {
167+
body: ResponseBody::None,
168+
headers: Headers::new(),
169+
status_code: StatusCode::BAD_REQUEST,
170+
});
171+
}
172+
Err(err) => {
173+
capture_graphql_error(err, request_context, StatusCode::OK);
174+
}
175+
Ok(_) => {}
135176
}
136177

137178
let mut headers = if let Ok(ref response) = response {

crates/server-common/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ postgres-resolver = { path = "../postgres-subsystem/postgres-resolver", features
1717
deno-resolver = { path = "../deno-subsystem/deno-resolver", optional = true }
1818
wasm-resolver = { path = "../wasm-subsystem/wasm-resolver", optional = true }
1919
exo-env = { path = "../../libs/exo-env" }
20+
sentry.workspace = true
2021

2122
[features]
2223
static-postgres-resolver = ["postgres-resolver"]

crates/server-common/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use std::{env, process::exit, sync::Arc};
1212
use thiserror::Error;
1313

1414
use common::logging_tracing::{self, OtelError};
15+
16+
mod sentry;
1517
use core_plugin_interface::interface::SubsystemLoader;
1618

1719
use core_router::SystemLoadingError;
@@ -29,6 +31,7 @@ use system_router::{SystemRouter, create_system_router_from_file};
2931
/// - 1 - If the exo_ir file doesn't exist or can't be loaded.
3032
pub async fn init(env: Arc<dyn Environment>) -> Result<SystemRouter, ServerInitError> {
3133
logging_tracing::init(env.as_ref()).await?;
34+
sentry::init(env.as_ref());
3235

3336
println!(
3437
"Exograph server starting (version {})",

crates/server-common/src/sentry.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use exo_env::Environment;
2+
use std::sync::OnceLock;
3+
4+
static SENTRY_GUARD: OnceLock<sentry::ClientInitGuard> = OnceLock::new();
5+
6+
fn get_env_value(env: &dyn Environment, key: &str) -> Option<String> {
7+
env.get(key)
8+
.map(|value| value.trim().to_string())
9+
.filter(|v| !v.is_empty())
10+
}
11+
12+
pub fn init(env: &dyn Environment) {
13+
if SENTRY_GUARD.get().is_some() {
14+
return;
15+
}
16+
17+
let dsn = get_env_value(env, "EXO_SENTRY_DSN").or_else(|| get_env_value(env, "SENTRY_DSN"));
18+
19+
let Some(dsn) = dsn else {
20+
return;
21+
};
22+
23+
let environment = get_env_value(env, "EXO_SENTRY_ENVIRONMENT")
24+
.or_else(|| get_env_value(env, "SENTRY_ENVIRONMENT"))
25+
.or_else(|| get_env_value(env, "EXO_ENV"));
26+
27+
let release =
28+
get_env_value(env, "EXO_SENTRY_RELEASE").or_else(|| get_env_value(env, "SENTRY_RELEASE"));
29+
30+
let sample_rate = get_env_value(env, "EXO_SENTRY_SAMPLE_RATE")
31+
.or_else(|| get_env_value(env, "SENTRY_SAMPLE_RATE"))
32+
.and_then(|value| value.parse::<f32>().ok())
33+
.unwrap_or(1.0);
34+
35+
let traces_sample_rate = get_env_value(env, "EXO_SENTRY_TRACES_SAMPLE_RATE")
36+
.or_else(|| get_env_value(env, "SENTRY_TRACES_SAMPLE_RATE"))
37+
.and_then(|value| value.parse::<f32>().ok())
38+
.unwrap_or(0.0);
39+
40+
let options = sentry::ClientOptions {
41+
dsn: dsn.parse().ok(),
42+
environment: environment.map(Into::into),
43+
release: release.map(Into::into),
44+
sample_rate,
45+
traces_sample_rate,
46+
..Default::default()
47+
};
48+
49+
let guard = sentry::init(options);
50+
let _ = SENTRY_GUARD.set(guard);
51+
}
52+
53+
#[allow(dead_code)]
54+
pub fn enabled() -> bool {
55+
sentry::Hub::current().client().is_some()
56+
}

0 commit comments

Comments
 (0)