From f52eda67201627a1e2f439a9c0c44ccf3679449a Mon Sep 17 00:00:00 2001 From: Mikael Karon Date: Wed, 14 Oct 2026 10:53:25 +0000 Subject: [PATCH] feat: compose a subgraph from its sdl A subgraph can now provide its schema as SDL with the `sdl` option. When it does, the composer builds the subgraph introspection from the SDL instead of requesting the composeEndpoint or sending the introspection query to the graphqlEndpoint, so composing does not depend on the subgraph being reachable. Queries are still sent to `server.host` and `server.graphqlEndpoint`, and entities work as for a fetched schema. The introspection is built with the options of the default introspection query, so a subgraph composed from its SDL gives the same supergraph as the same subgraph composed from its server. An SDL that can not be parsed or is not a valid schema is reported to onSubgraphError, as a schema that can not be fetched is. Signed-off-by: Mikael Karon Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 30 +++++- lib/composer.js | 27 ++++- lib/validation.js | 4 + test/sdl.test.js | 248 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 test/sdl.test.js diff --git a/README.md b/README.md index c77bf1f..b1e50d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The GraphQL API Composer is a framework agnostic library for combining multiple GraphQL APIs, known as subgraphs, into a single API capable of querying data across any of its constituent subgraphs. -The Composer downloads each subgraph schema from the corresponding upstream servers. The subgraphs will be merged to create a supergraph API. The combined API exposes the original operations from each subgraph. Furthermore, types with the same name in multiple subgraphs are merged together into entities. +The Composer downloads each subgraph schema from the corresponding upstream servers, or takes it from the SDL provided for the subgraph. The subgraphs will be merged to create a supergraph API. The combined API exposes the original operations from each subgraph. Furthermore, types with the same name in multiple subgraphs are merged together into entities. ## Getting Started @@ -189,6 +189,7 @@ main() - `host` (string, required) - The host information to connect to. - `composeEndpoint` (string, optional) - The endpoint to retrieve the introspection query from. **Default:** `'/.well-known/graphql-composition'`. In case the endpoint is not available, a second call with introspection query will be sent to the `graphqlEndpoint`. - `graphqlEndpoint` (string, optional) - The endpoint to make GraphQL queries against. **Default:** `'/graphql'`. + - `sdl` (string, optional) - The schema of the subgraph, as SDL. When provided, the Composer uses it instead of retrieving the schema from the subgraph server, so composing does not need the subgraph to be reachable and `composeEndpoint` is not used; queries are still sent to `server.host` and `server.graphqlEndpoint`. The SDL must be a complete, valid schema, for example the output of `printSchema()` of the subgraph schema. See [subgraph schema from SDL](#subgraph-schema-from-sdl). - `entities` (object, optional) - Configuration object for working with entities in this subgraph, the values are objects with the the following schema: - `resolver` (object, optional) - The resolver to retrieve a list of objects - should return a list - and should accept as a arguments a list of primary keys or foreign keys. - `name` (string, required) - The name of the resolver. @@ -221,6 +222,33 @@ main() When this function is called, all of the subgraph schemas are retrieved and merged into a supergraph. A `Promise` resolving to a `Composer` instance is returned. +### Subgraph schema from SDL + +By default, the Composer retrieves the schema of each subgraph from its server when composing: it requests the `composeEndpoint` and, if that is not available, sends the introspection query to the `graphqlEndpoint`. A subgraph can provide its schema instead, as SDL, for example from a file published along with the subgraph: + +```js +const { readFile } = require('node:fs/promises') +const { compose } = require('@platformatic/graphql-composer') + +const composer = await compose({ + subgraphs: [ + { + name: 'books', + // queries are still sent to the server + server: { host: 'http://books.example.com', graphqlEndpoint: '/graphql' }, + // the schema is not fetched from the server + sdl: await readFile('./schemas/books.graphql', 'utf8') + }, + { + name: 'reviews', + server: { host: 'http://reviews.example.com' } + } + ] +}) +``` + +The subgraph does not need to be running while composing; a query fails only when it is sent to a subgraph that is not reachable. Entities are configured in the same way for a subgraph whose schema comes from SDL. If the SDL is not valid, the error is passed to `onSubgraphError`, as when the schema can not be retrieved from the subgraph server. + ### `Composer` class Instances of the `Composer` class are returned from the `compose()` function. The `Composer` constructor is not exposed as part of the public API. diff --git a/lib/composer.js b/lib/composer.js index 796b847..ff58c3e 100644 --- a/lib/composer.js +++ b/lib/composer.js @@ -1,6 +1,6 @@ 'use strict' -const { buildClientSchema, printSchema } = require('graphql') +const { buildClientSchema, buildSchema, introspectionFromSchema, printSchema } = require('graphql') const { getIntrospectionQuery } = require('graphql') const fastify = require('fastify') const mercurius = require('mercurius') @@ -17,6 +17,22 @@ const { createDefaultHeadersAdapter } = require('./headers') const COMPOSER_SUBGRAPH_NAME = '__composer__' +/** + * build the introspection of a subgraph from its SDL, in the same shape + * as the one returned by the subgraph to the default introspection query + * @param {string} sdl + * @returns {Object} introspection, { __schema } + */ +function introspectionFromSdl (sdl) { + return introspectionFromSchema(buildSchema(sdl), { + specifiedByUrl: false, + directiveIsRepeatable: false, + schemaDescription: false, + inputValueDeprecation: false, + oneOf: false + }) +} + /** * @typedef {Object} ComposerField * @property {Object} src - graphql field definition @@ -245,7 +261,11 @@ class Composer { async fetchSubgraphSchemas () { const subgraphs = Array.from(this.subgraphs.values()) - const requests = subgraphs.map((subgraph) => { + const requests = subgraphs.map(async (subgraph) => { + // the subgraph schema is provided, no need to fetch it from the subgraph server + if (subgraph.sdl !== undefined) { + return introspectionFromSdl(subgraph.sdl) + } return fetchSubgraphSchema(subgraph.server, this.composeEndpointMissCache) }) @@ -257,7 +277,8 @@ class Composer { const subgraph = subgraphs[i] if (status !== 'fulfilled') { - const msg = `Could not process schema for subgraph '${subgraph.name}' from '${subgraph.server.host}'` + const source = subgraph.sdl === undefined ? `'${subgraph.server.host}'` : 'the provided SDL' + const msg = `Could not process schema for subgraph '${subgraph.name}' from ${source}` this.onSubgraphError(new Error(msg, { cause: responses[i].reason }), subgraph.name) continue diff --git a/lib/validation.js b/lib/validation.js index 5064b6d..0f4a37b 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -93,6 +93,10 @@ function validateSubgraphOptions (subgraph, subgraphName, defaultArgsAdapter) { entities: new Map() } + if (subgraph.sdl !== undefined) { + validatedSubgraph.sdl = validateString(subgraph.sdl, optionName + '.sdl') + } + if (subgraph.entities) { const optionEntitiesName = optionName + '.entities' validateObject(subgraph.entities, optionEntitiesName) diff --git a/test/sdl.test.js b/test/sdl.test.js new file mode 100644 index 0000000..7b2b039 --- /dev/null +++ b/test/sdl.test.js @@ -0,0 +1,248 @@ +'use strict' + +const assert = require('node:assert') +const path = require('node:path') +const { test } = require('node:test') + +const { compose } = require('../') +const { createComposerService, createGraphqlServices, graphqlRequest } = require('./helper') + +const gql = { + schema: 'type Query {\n add(x: Int, y: Int): Int\n}', + resolvers: { Query: { add: (_, { x, y }) => x + y } } +} + +async function createTrackedGraphqlService (t, { schema, resolvers }) { + const requests = [] + const [subgraph] = await createGraphqlServices(t, [{ + mercurius: { schema, resolvers }, + exposeIntrospection: {} + }]) + subgraph.service.addHook('onRequest', async (request) => { + requests.push(request.url) + }) + subgraph.service.addHook('preHandler', async (request) => { + if (request.body?.query?.includes('__schema')) { + requests.push('introspection') + } + }) + subgraph.host = await subgraph.service.listen() + + return { ...subgraph, requests } +} + +test('should compose a subgraph from sdl without fetching its schema', async (t) => { + const subgraph = await createTrackedGraphqlService(t, gql) + + let errors = 0 + const options = { + onSubgraphError: () => { errors++ }, + subgraphs: [{ + name: 'math', + server: { host: subgraph.host }, + sdl: gql.schema + }] + } + const { composer, service } = await createComposerService(t, { compose, options }) + + assert.strictEqual(errors, 0) + assert.strictEqual(composer.toSdl(), gql.schema) + assert.deepStrictEqual(subgraph.requests, []) + + const result = await graphqlRequest(service, '{ add(x: 2, y: 3) }') + assert.deepStrictEqual(result, { add: 5 }) + assert.deepStrictEqual(subgraph.requests, ['/graphql']) +}) + +test('should send the queries of a subgraph from sdl to its graphqlEndpoint', async (t) => { + const [subgraph] = await createGraphqlServices(t, [{ + mercurius: { ...gql, path: '/custom-graphql' }, + listen: true + }]) + + const options = { + subgraphs: [{ + server: { host: subgraph.host, graphqlEndpoint: '/custom-graphql' }, + sdl: gql.schema + }] + } + const { service } = await createComposerService(t, { compose, options }) + + const result = await graphqlRequest(service, '{ add(x: 4, y: 5) }') + assert.deepStrictEqual(result, { add: 9 }) +}) + +test('should compose the same supergraph from sdl as from the subgraph server', async (t) => { + const books = require('./fixtures/books') + const reviews = require('./fixtures/reviews') + + const services = await createGraphqlServices(t, [ + { name: 'books-subgraph', file: path.join(__dirname, 'fixtures/books.js'), listen: true }, + { name: 'reviews-subgraph', file: path.join(__dirname, 'fixtures/reviews.js'), listen: true } + ]) + + const fetched = await compose({ + subgraphs: services.map(service => ({ + name: service.name, + entities: service.config.entities, + server: { host: service.host } + })) + }) + + const fromSdl = await compose({ + subgraphs: [ + { name: 'books-subgraph', entities: books.entities, server: { host: services[0].host }, sdl: books.schema }, + { name: 'reviews-subgraph', entities: reviews.entities, server: { host: services[1].host }, sdl: reviews.schema } + ] + }) + + assert.strictEqual(fromSdl.toSdl(), fetched.toSdl()) +}) + +test('should compose a subgraph from sdl while the subgraph server is down, and fail only the queries sent to it', async (t) => { + const subgraph = await createTrackedGraphqlService(t, gql) + await subgraph.service.close() + + let errors = 0 + const options = { + onSubgraphError: () => { errors++ }, + subgraphs: [{ + name: 'math', + server: { host: subgraph.host }, + sdl: gql.schema + }] + } + const { composer, service } = await createComposerService(t, { compose, options }) + + assert.strictEqual(errors, 0) + assert.strictEqual(composer.toSdl(), gql.schema) + + await assert.rejects(graphqlRequest(service, '{ add(x: 2, y: 3) }'), (errors) => { + assert.match(errors[0].message, /ECONNREFUSED/) + return true + }) +}) + +test('should compose a subgraph from sdl and a subgraph from its server, with entities across them', async (t) => { + const reviews = require('./fixtures/reviews') + + const services = await createGraphqlServices(t, [ + { name: 'books-subgraph', file: path.join(__dirname, 'fixtures/books.js'), listen: true }, + { name: 'reviews-subgraph', file: path.join(__dirname, 'fixtures/reviews.js') } + ]) + + const requests = [] + services[1].service.addHook('preHandler', async (request) => { + requests.push(request.body?.query?.includes('__schema') ? 'introspection' : request.url) + }) + services[1].host = await services[1].service.listen() + + const options = { + subgraphs: [ + { + name: 'books-subgraph', + entities: services[0].config.entities, + server: { host: services[0].host } + }, + { + name: 'reviews-subgraph', + entities: services[1].config.entities, + server: { host: services[1].host }, + sdl: reviews.schema + } + ] + } + const { service } = await createComposerService(t, { compose, options }) + + const result = await graphqlRequest(service, '{ getBook(id: 1) { id, title, genre, rate } }') + assert.deepStrictEqual(result, { + getBook: { + id: '1', + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + rate: 3 + } + }) + // the reviews subgraph only received the query, neither the compose request nor the introspection query + assert.ok(requests.length > 0) + assert.ok(requests.every(request => request === '/graphql')) +}) + +test('should not probe the composeEndpoint of a subgraph from sdl', async (t) => { + let composeEndpointHits = 0 + const [subgraph] = await createGraphqlServices(t, [{ + mercurius: { ...gql }, + exposeIntrospection: {}, + onComposeEndpointHit: () => { composeEndpointHits++ }, + listen: true + }]) + + const composeEndpointMissCache = new Map() + await compose({ + composeEndpointMissCache, + subgraphs: [{ server: { host: subgraph.host, composeEndpoint: '/.well-known/graphql-composition' }, sdl: gql.schema }] + }) + + assert.strictEqual(composeEndpointHits, 0) + assert.strictEqual(composeEndpointMissCache.size, 0) +}) + +test('should call onSubgraphError on invalid sdl', async (t) => { + let errors = 0 + const composer = await compose({ + onSubgraphError: (error, subgraphName) => { + assert.strictEqual(subgraphName, 'math') + assert.strictEqual(error.message, "Could not process schema for subgraph 'math' from the provided SDL") + assert.match(error.cause.message, /Syntax Error/) + errors++ + }, + subgraphs: [{ + name: 'math', + server: { host: 'http://unreachable.local' }, + sdl: 'type Query { add(x: Int, y: Int): Int' + }] + }) + + assert.strictEqual(errors, 1) + assert.strictEqual(composer.schemas.length, 0) +}) + +test('should call onSubgraphError on sdl that is not a valid schema', async (t) => { + let errors = 0 + await compose({ + onSubgraphError: (error) => { + assert.strictEqual(error.message, "Could not process schema for subgraph '#0' from the provided SDL") + assert.match(error.cause.message, /Unknown type "Missing"/) + errors++ + }, + subgraphs: [{ + server: { host: 'http://unreachable.local' }, + sdl: 'type Query { missing: Missing }' + }] + }) + + assert.strictEqual(errors, 1) +}) + +test('should throw on invalid sdl by default', async (t) => { + await assert.rejects(compose({ + subgraphs: [{ + server: { host: 'http://unreachable.local' }, + sdl: '' + }] + }), (error) => { + assert.strictEqual(error.message, "Could not process schema for subgraph '#0' from the provided SDL") + assert.match(error.cause.message, /Syntax Error/) + return true + }) +}) + +test('should throw if sdl is not a string', async (t) => { + await assert.rejects(compose({ + subgraphs: [{ + name: 'math', + server: { host: 'http://unreachable.local' }, + sdl: { kind: 'Document' } + }] + }), new TypeError('subgraphs[math].sdl must be a string')) +})