ITADN

BUG: Hive Router produces malformed JSON in `_entities` request when `@requires` field is null

#880Closedmarbergq 创建于 2026-03-26
bug
M
marbergqcommented
# Bug: Hive Router produces malformed JSON in `_entities` request when `@requires` field is null ## Summary When a subgraph returns `null` for a field referenced in a `@requires` directive, the Hive Router produces malformed JSON (double opening braces `{{`) in the subsequent `_entities` request to the requiring subgraph. The root entity `__typename` is also missing from the representation. ## Reproduction ### Prerequisites - Docker - [Apollo Rover CLI](https://www.apollographql.com/docs/rover/) (`npm install -g @apollo/rover`) ### File structure ``` repro/ ├── docker-compose.yaml ├── router.yaml ├── supergraph.yaml ├── supergraph.graphql # generated by rover ├── subgraph-ads/ │ ├── Dockerfile │ ├── schema.graphql │ └── index.mjs └── subgraph-organizations/ ├── Dockerfile ├── schema.graphql └── index.mjs ``` ### Schemas **subgraph-organizations/schema.graphql** - Owns `Branch` with a nullable `@shareable` type: ```graphql extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" import: ["@key", "@shareable"] ) type Query { branch(id: ID!): Branch } type Branch @key(fields: "id") { id: ID! contactOptions: ContactOptions } type ContactOptions @shareable { email: String user: BranchUser } type BranchUser @shareable { id: ID! name: String } ``` **subgraph-ads/schema.graphql** - Uses `@requires` to pull data from the organizations subgraph: ```graphql extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" import: ["@key", "@shareable", "@external", "@requires", "@extends"] ) type Query { ad(id: ID!): Ad } type Ad @key(fields: "id") { id: ID! branch: Branch contactOptions: ContactOptions @requires(fields: "branch { contactOptions { email user { id name } } }") } type Branch @key(fields: "id", resolvable: false) @extends { id: ID! @external contactOptions: ContactOptions @external } type ContactOptions @shareable { email: String user: BranchUser } type BranchUser @shareable { id: ID! name: String } ``` ### Subgraph implementations Both subgraphs are minimal Node.js HTTP servers with zero dependencies. **subgraph-organizations/index.mjs** - Returns `contactOptions: null` for the branch (simulating a branch with no contact options configured): ```js import http from "http"; function handleQuery(body) { const { query, variables } = body; if (query.includes("_entities")) { const results = (variables.representations || []).map((rep) => ({ __typename: "Branch", id: rep.id, contactOptions: null, })); return { data: { _entities: results } }; } return { data: null }; } const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { console.log("[orgs] <<< " + body); const result = handleQuery(JSON.parse(body)); const out = JSON.stringify(result); console.log("[orgs] >>> " + out); res.writeHead(200, { "content-type": "application/json" }); res.end(out); }); }); server.listen(4002, () => console.log("[orgs] ready on 4002")); ``` **subgraph-ads/index.mjs** - Handles regular queries and `_entities` with `@requires` data: ```js import http from "http"; function handleQuery(body) { const { query, variables } = body; if (query.includes("_entities")) { const results = (variables.representations || []).map((rep) => ({ __typename: "Ad", id: rep.id, contactOptions: rep.branch?.contactOptions ?? null, })); return { data: { _entities: results } }; } if (query.includes("ad(")) { return { data: { ad: { id: "1", branch: { __typename: "Branch", id: "branch-1" }, }, }, }; } return { data: null }; } const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { console.log("[ads] <<< " + body); let parsed; try { parsed = JSON.parse(body); } catch (e) { console.log("[ads] !!! MALFORMED JSON from router: " + e.message); console.log("[ads] !!! Raw body: " + body); res.writeHead(400, { "content-type": "application/json" }); res.end(JSON.stringify({ errors: [{ message: e.message }] })); return; } const result = handleQuery(parsed); const out = JSON.stringify(result); console.log("[ads] >>> " + out); res.writeHead(200, { "content-type": "application/json" }); res.end(out); }); }); server.listen(4001, () => console.log("[ads] ready on 4001")); ``` ### Dockerfiles Both subgraphs use the same Dockerfile: ```dockerfile FROM node:22-slim WORKDIR /app COPY index.mjs . CMD ["node", "index.mjs"] ``` ### Docker Compose **docker-compose.yaml**: ```yaml services: organizations: build: ./subgraph-organizations ports: - "4002:4002" ads: build: ./subgraph-ads ports: - "4001:4001" hive-router: image: ghcr.io/graphql-hive/router:latest ports: - "4010:4000" environment: - ROUTER_CONFIG_FILE_PATH=/app/config/router.yaml volumes: - ./router.yaml:/app/config/router.yaml:ro - ./supergraph.graphql:/app/config/supergraph.graphql:ro depends_on: - organizations - ads ``` **router.yaml**: ```yaml supergraph: source: file path: /app/config/supergraph.graphql ``` **supergraph.yaml** (rover composition config): ```yaml federation_version: =2.3.2 subgraphs: organizations: routing_url: http://organizations:4002 schema: file: ./subgraph-organizations/schema.graphql ads: routing_url: http://ads:4001 schema: file: ./subgraph-ads/schema.graphql ``` ### Steps ```bash # 1. Compose the supergraph rover supergraph compose --config supergraph.yaml > supergraph.graphql # 2. Start everything docker compose up --build # 3. Send query (in another terminal) curl -X POST http://localhost:4010/graphql \ -H "Content-Type: application/json" \ -d '{"query":"{ ad(id: \"1\") { id contactOptions { email } } }"}' ``` ## What happens The router executes three steps: **Step 1** - Router queries `ads` for the ad with its branch reference: ``` Router -> ads: {ad(id: "1"){id branch{__typename id}}} ads -> Router: {"data":{"ad":{"id":"1","branch":{"__typename":"Branch","id":"branch-1"}}}} ``` **Step 2** - Router queries `organizations` to fetch the `@requires` data: ``` Router -> orgs: _entities(representations: [{"__typename":"Branch","id":"branch-1"}]) selecting: { contactOptions { email user { name id } } } orgs -> Router: {"data":{"_entities":[{"__typename":"Branch","id":"branch-1","contactOptions":null}]}} ``` **Step 3** - Router constructs `_entities` request back to `ads` with `@requires` data — **this is where the bug occurs**: ``` Router -> ads: {"variables":{"representations":[{{"id":"1"}]}} ^^ MALFORMED: double brace, missing __typename ``` The ads subgraph cannot parse this as valid JSON: ``` JSON parse error: Expected property name or '}' at position 156 ``` ## Expected behavior The router should send valid JSON with `__typename` included: ```json {"representations": [{"__typename": "Ad", "id": "1", "branch": {"contactOptions": null}}]} ``` Or, if the `@requires` data is entirely null, the router should either: - Include the null data in the representation as-is - Return null for the field without sending a malformed downstream request ## Additional observation When the `@requires` field data is **non-null**, the router sends valid JSON but still omits `__typename` from the root entity representation. It does include `__typename` correctly for nested entities. This is a separate (less severe) issue. ## Environment - Router image: `ghcr.io/graphql-hive/router:latest` - Supergraph composed with `rover supergraph compose` (Federation v2.3.2) - Subgraphs: plain Node.js HTTP servers (no framework dependencies)
关闭于 2026-03-29 2 条评论