BUG: Hive Router produces malformed JSON in `_entities` request when `@requires` field is null
bug
# 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 条评论