Skip to content
Merged
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 24 additions & 3 deletions lib/composer.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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)
})

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
248 changes: 248 additions & 0 deletions test/sdl.test.js
Original file line number Diff line number Diff line change
@@ -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'))
})
Loading