Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(router-bridge): add validation function to export #408

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
[workspace]
members = [
"apollo-federation-types",
"xtask"
]
members = ["apollo-federation-types", "xtask"]
resolver = "2"
7 changes: 2 additions & 5 deletions federation-2/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions federation-2/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[workspace]
members = ["harmonizer", "supergraph", "router-bridge"]
resolver = "2"
14 changes: 14 additions & 0 deletions federation-2/router-bridge/js-src/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
validate,
printSchema,
graphqlSync,
Source,
} from "graphql";

import {
Expand Down Expand Up @@ -248,6 +249,19 @@ export class BridgeQueryPlanner {
}
}

validate(query: string): Map<string, string> {
let schema = this.supergraph.schema.toGraphQLJSSchema();
let op = parse(new Source(query, "op.graphql"));
let validationErrors = validate(schema, op);

let result = new Map<string, string>();
validationErrors.forEach((err) => {
result.set(err.name, err.message);
});

return result;
}

operationSignature(
operationString: string,
providedOperationName?: string
Expand Down
14 changes: 14 additions & 0 deletions federation-2/router-bridge/js-src/plan_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ enum PlannerEventKind {
Exit = "Exit",
ApiSchema = "ApiSchema",
Introspect = "Introspect",
Validate = "Validate",
Signature = "Signature",
Subgraphs = "Subgraphs",
}
Expand Down Expand Up @@ -57,6 +58,12 @@ interface IntrospectEvent {
schemaId: number;
}

interface ValidateEvent {
kind: PlannerEventKind.Validate;
query: string;
schemaId: number;
}

interface SignatureEvent {
kind: PlannerEventKind.Signature;
query: string;
Expand All @@ -77,6 +84,7 @@ type PlannerEvent =
| UpdateSchemaEvent
| PlanEvent
| ApiSchemaEvent
| ValidateEvent
| IntrospectEvent
| SignatureEvent
| SubgraphsEvent
Expand Down Expand Up @@ -272,6 +280,12 @@ async function run() {
.introspect(event.query);
await send({ id, payload: introspectResult });
break;
case PlannerEventKind.Validate:
const validateResult = planners
.get(event.schemaId)
.validate(event.query);
await send({ id, payload: validateResult });
break;
case PlannerEventKind.Signature:
const signature = planners
.get(event.schemaId)
Expand Down
2 changes: 1 addition & 1 deletion federation-2/router-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@
"node": "16.20.0",
"npm": "9.7.1"
}
}
}
130 changes: 130 additions & 0 deletions federation-2/router-bridge/src/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,19 @@ where
.await
}

/// Run GraphQL JS validation
pub async fn validate(
&self,
query: String
) -> Result<HashMap<String, String>, crate::error::Error> {
self.worker
.request(PlanCmd::Validate{
query,
schema_id: self.schema_id,
})
.await
}

/// Get the operation signature for a query
pub async fn operation_signature(
&self,
Expand Down Expand Up @@ -628,6 +641,8 @@ enum PlanCmd {
#[serde(rename_all = "camelCase")]
Introspect { query: String, schema_id: u64 },
#[serde(rename_all = "camelCase")]
Validate { query: String, schema_id: u64 },
#[serde(rename_all = "camelCase")]
Signature {
query: String,
operation_name: Option<String>,
Expand Down Expand Up @@ -2001,4 +2016,119 @@ feature https://specs.apollo.dev/unsupported-feature/v0.1 is for: SECURITY but i
.data
.unwrap()).unwrap());
}

#[tokio::test]
async fn js_validation() {
let schema = r#"
schema @core(feature: "https://specs.apollo.dev/core/v0.1") @core(feature: "https://specs.apollo.dev/join/v0.1") {
query: Query
mutation: Mutation
}
directive @core(feature: String!) repeatable on SCHEMA

directive @join__field(
graph: join__Graph
requires: join__FieldSet
provides: join__FieldSet
) on FIELD_DEFINITION

directive @join__type(
graph: join__Graph!
key: join__FieldSet
) repeatable on OBJECT | INTERFACE

directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

# Uncomment if you want to reproduce the bug with the order of skip/include directives
# directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
# directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

scalar join__FieldSet @specifiedBy(url: "example.com")

enum join__Graph {
ACCOUNTS @join__graph(name: "accounts", url: "http://subgraphs:4001/graphql")
INVENTORY
@join__graph(name: "inventory", url: "http://subgraphs:4004/graphql")
PRODUCTS @join__graph(name: "products", url: "http://subgraphs:4003/graphql")
REVIEWS @join__graph(name: "reviews", url: "http://subgraphs:4002/graphql")
}

type Mutation {
createProduct(name: String, upc: ID!): Product @join__field(graph: PRODUCTS)
createReview(body: String, id: ID!, upc: ID!): Review
@join__field(graph: REVIEWS)
}

type Product
@join__owner(graph: PRODUCTS)
@join__type(graph: PRODUCTS, key: "upc")
@join__type(graph: INVENTORY, key: "upc")
@join__type(graph: REVIEWS, key: "upc") {
inStock: Boolean @join__field(graph: INVENTORY)
name: String @join__field(graph: PRODUCTS)
price: Int @join__field(graph: PRODUCTS)
reviews: [Review] @join__field(graph: REVIEWS)
reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS)
shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight")
upc: String! @join__field(graph: PRODUCTS)
weight: Int @join__field(graph: PRODUCTS)
}

type Query {
me: User @join__field(graph: ACCOUNTS)
topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS)
}

type Review
@join__owner(graph: REVIEWS)
@join__type(graph: REVIEWS, key: "id") {
author: User @join__field(graph: REVIEWS, provides: "username")
body: String @join__field(graph: REVIEWS)
id: ID! @join__field(graph: REVIEWS)
product: Product @join__field(graph: REVIEWS)
}

type User
@join__owner(graph: ACCOUNTS)
@join__type(graph: ACCOUNTS, key: "id")
@join__type(graph: REVIEWS, key: "id") {
id: ID! @join__field(graph: ACCOUNTS)
name: String @join__field(graph: ACCOUNTS)
reviews: [Review] @join__field(graph: REVIEWS)
username: String @join__field(graph: ACCOUNTS)
} "#;

let op = r#" {
createProduct(name: "A", upc: 0) {
inStockk
inStock
}
}
"#;

let planner = Planner::<serde_json::Value>::new(
schema.to_string(),
QueryPlannerConfig::default(),
)
.await
.unwrap();

let maybe_diag = planner.validate(op.to_string()).await.unwrap();
let errors: Option<Vec<(String, String)>> = if maybe_diag.is_empty() {
None
} else {
let errors = maybe_diag
.into_iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some(errors)
};
let errors: Vec<(String, String)> = errors.expect("expected errors");
assert_eq!(errors.len(), 1);
let _ = errors.into_iter().map(|(_err, msg)| {
insta::assert_snapshot!(msg);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: router-bridge/src/validate.rs
expression: validated.errors
---
[
{
"message": "Cannot query field \"me\" on type \"Query\"."
}
]
15 changes: 11 additions & 4 deletions federation-2/router-bridge/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@
"noUnusedLocals": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"lib": ["es2021", "esnext.asynciterable"],
"lib": [
"es2021",
"esnext.asynciterable"
],
"rootDir": "./js-src",
"outDir": "./js-dist",
"types": [],
"allowJs": true,
"strict": false,
},
"include": ["./js-src/**/*"],
"exclude": ["**/__tests__"]
}
"include": [
"./js-src/**/*"
],
"exclude": [
"**/__tests__"
]
}