diff --git a/Cargo.toml b/Cargo.toml index ff5c9948..5e48b887 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,3 @@ [workspace] -members = [ - "apollo-federation-types", - "xtask" -] +members = ["apollo-federation-types", "xtask"] +resolver = "2" diff --git a/federation-2/Cargo.lock b/federation-2/Cargo.lock index 552efc95..a2f2c686 100644 --- a/federation-2/Cargo.lock +++ b/federation-2/Cargo.lock @@ -1268,12 +1268,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "matchers" diff --git a/federation-2/Cargo.toml b/federation-2/Cargo.toml index dfea256a..0587d3ac 100644 --- a/federation-2/Cargo.toml +++ b/federation-2/Cargo.toml @@ -1,2 +1,3 @@ [workspace] members = ["harmonizer", "supergraph", "router-bridge"] +resolver = "2" diff --git a/federation-2/router-bridge/js-src/plan.ts b/federation-2/router-bridge/js-src/plan.ts index 410a6502..4da80fb8 100644 --- a/federation-2/router-bridge/js-src/plan.ts +++ b/federation-2/router-bridge/js-src/plan.ts @@ -13,6 +13,7 @@ import { validate, printSchema, graphqlSync, + Source, } from "graphql"; import { @@ -248,6 +249,19 @@ export class BridgeQueryPlanner { } } + validate(query: string): Map { + let schema = this.supergraph.schema.toGraphQLJSSchema(); + let op = parse(new Source(query, "op.graphql")); + let validationErrors = validate(schema, op); + + let result = new Map(); + validationErrors.forEach((err) => { + result.set(err.name, err.message); + }); + + return result; + } + operationSignature( operationString: string, providedOperationName?: string diff --git a/federation-2/router-bridge/js-src/plan_worker.ts b/federation-2/router-bridge/js-src/plan_worker.ts index e94ad796..ce3aaae2 100644 --- a/federation-2/router-bridge/js-src/plan_worker.ts +++ b/federation-2/router-bridge/js-src/plan_worker.ts @@ -29,6 +29,7 @@ enum PlannerEventKind { Exit = "Exit", ApiSchema = "ApiSchema", Introspect = "Introspect", + Validate = "Validate", Signature = "Signature", Subgraphs = "Subgraphs", } @@ -57,6 +58,12 @@ interface IntrospectEvent { schemaId: number; } +interface ValidateEvent { + kind: PlannerEventKind.Validate; + query: string; + schemaId: number; +} + interface SignatureEvent { kind: PlannerEventKind.Signature; query: string; @@ -77,6 +84,7 @@ type PlannerEvent = | UpdateSchemaEvent | PlanEvent | ApiSchemaEvent + | ValidateEvent | IntrospectEvent | SignatureEvent | SubgraphsEvent @@ -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) diff --git a/federation-2/router-bridge/package.json b/federation-2/router-bridge/package.json index 768589b6..6bfbc163 100644 --- a/federation-2/router-bridge/package.json +++ b/federation-2/router-bridge/package.json @@ -49,4 +49,4 @@ "node": "16.20.0", "npm": "9.7.1" } -} +} \ No newline at end of file diff --git a/federation-2/router-bridge/src/planner.rs b/federation-2/router-bridge/src/planner.rs index 2a532307..2d9cdd30 100644 --- a/federation-2/router-bridge/src/planner.rs +++ b/federation-2/router-bridge/src/planner.rs @@ -562,6 +562,19 @@ where .await } + /// Run GraphQL JS validation + pub async fn validate( + &self, + query: String + ) -> Result, 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, @@ -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, @@ -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::::new( + schema.to_string(), + QueryPlannerConfig::default(), + ) + .await + .unwrap(); + + let maybe_diag = planner.validate(op.to_string()).await.unwrap(); + let errors: Option> = 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); + }); + } } diff --git a/federation-2/router-bridge/src/snapshots/router_bridge__validate__tests__it_works.snap b/federation-2/router-bridge/src/snapshots/router_bridge__validate__tests__it_works.snap new file mode 100644 index 00000000..8129427e --- /dev/null +++ b/federation-2/router-bridge/src/snapshots/router_bridge__validate__tests__it_works.snap @@ -0,0 +1,9 @@ +--- +source: router-bridge/src/validate.rs +expression: validated.errors +--- +[ + { + "message": "Cannot query field \"me\" on type \"Query\"." + } +] diff --git a/federation-2/router-bridge/tsconfig.json b/federation-2/router-bridge/tsconfig.json index 13083ea1..1087a831 100644 --- a/federation-2/router-bridge/tsconfig.json +++ b/federation-2/router-bridge/tsconfig.json @@ -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__" + ] +} \ No newline at end of file