Skip to content
Open
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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,60 @@ async function main () {
main()
```

## Same-named types across subgraphs

Subgraphs may publish types and root fields with the same name. The `onConflict` option decides how they are merged:

| `onConflict` | same-named enum | field both declare on a same-named object type | shared `Query` / `Mutation` field |
| --- | --- | --- | --- |
| unset (default) | first subgraph's values | last declaration | last subgraph's resolver, with a warning |
| `'error'` | union of the values | first declaration | composition fails, naming the field and the subgraphs |
| `'first'` | first subgraph's values | first declaration | first subgraph's resolver, with a warning |
| `'last'` | last subgraph's values | last declaration | last subgraph's resolver, with a warning |
| `'route'` | union of the values | first declaration | routed to one subgraph by an enum argument |

"First" and "last" follow the order of `subgraphs`. A merged enum value keeps the description of the subgraph that declared it.

With the option unset, a shared enum keeps the first subgraph's values while a shared root field is answered by the last subgraph, so a call can validate against one subgraph and run against another. Set `onConflict` to choose a consistent strategy; the default will change in a future major version.

### Routing a shared root field

With `onConflict: 'route'`, a `Query` or `Mutation` field published by more than one subgraph is routed by one of its arguments. Every subgraph must declare the field with the same return type and the same argument types, and one argument must be an enum, declared as `Enum!` or with a default value, whose values are declared by exactly one subgraph each. The value a client passes selects the subgraph that receives the call.

Given a `Books` subgraph:

```graphql
enum IndexedEntity { BOOK }
type IndexResult { indexed: Int! }
type Mutation { reindex(entity: IndexedEntity!): IndexResult! }
```

and a `Reviews` subgraph:

```graphql
enum IndexedEntity { REVIEW AUTHOR }
type IndexResult { indexed: Int! }
type Mutation { reindex(entity: IndexedEntity!): IndexResult! }
```

the composed schema is:

```graphql
enum IndexedEntity { BOOK REVIEW AUTHOR }
type IndexResult { indexed: Int! }
type Mutation { reindex(entity: IndexedEntity!): IndexResult! }
```

`reindex(entity: BOOK)` is sent to `Books`, `reindex(entity: AUTHOR)` to `Reviews`, and a value outside the enum is rejected by the composed schema before any subgraph is called.

Composition fails, rather than picking a subgraph silently, when a shared root field cannot be routed:

- the subgraphs disagree on the return type or on an argument's type, or on the default of any argument other than the routing one
- no argument is an enum with a distinct set of values per subgraph; a nullable enum argument without a default does not qualify, since a client may leave it out
- a value is declared by more than one subgraph
- more than one enum argument could route the field
- a subgraph that does not publish the field adds values to the enum, since such a value would pass validation with nowhere to go

## API

### `compose(config)`
Expand Down Expand Up @@ -213,6 +267,7 @@ main()
- `onSubgraphError` (function, optional) - Hook called when an error occurs getting schema from a subgraph. The default function will throw the error. The arguments are:
- `error` (error) - The error.
- `subgraph` (string) - The erroring subgraph name.
- `onConflict` (string, optional) - How a type or root field that more than one subgraph publishes is merged: `'error'`, `'first'`, `'last'` or `'route'`, see [same-named types across subgraphs](#same-named-types-across-subgraphs). **Default:** unset: the first subgraph's values for a shared enum and the last subgraph's resolver for a shared root field, with a warning.
- `queryTypeName` (string, optional) - The name of the `Query` type in the composed schema. **Default:** `'Query'`.
- `mutationTypeName` (string, optional) - The name of the `Mutation` type in the composed schema. **Default:** `'Mutation'`.

Expand Down
186 changes: 184 additions & 2 deletions lib/composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class Composer {
this.mergedSchema = { _built: false }
this.schemas = []
this.mainTypes = new Set()
// the Query and Mutation type names actually seen in the subgraphs
this.mainTypeNames = new Set()
this.resolvers = Object.create(null)

const v = validateComposerOptions(options)
Expand All @@ -65,6 +67,7 @@ class Composer {
}
this.addEntitiesResolvers = v.addEntitiesResolvers
this.onSubgraphError = v.onSubgraphError
this.onConflict = v.onConflict
this.subgraphs = v.subgraphs
this.defaultArgsAdapter = v.defaultArgsAdapter
this.headersAdapter = createDefaultHeadersAdapter()
Expand Down Expand Up @@ -121,6 +124,7 @@ class Composer {
}

this.buildMergedSchema()
this.buildSharedFieldResolvers()
}

// TODO return a copy to avoid writes on resolvers
Expand Down Expand Up @@ -164,8 +168,8 @@ class Composer {

const t = this.mergedSchema.types.get(typeName)

// TODO handle conflicts by name or type, add option to rename, mask or hide
this.mergedSchema.types.set(typeName, t ? mergeTypes(t, type) : objectDeepClone(type))
// TODO add option to rename, mask or hide conflicting fields
this.mergedSchema.types.set(typeName, t ? mergeTypes(t, type, this.onConflict) : objectDeepClone(type))
}
}

Expand Down Expand Up @@ -289,6 +293,7 @@ class Composer {
const mainType = getMainType(schema, schemaType)
if (mainType) {
this.mainTypes.add(mainType)
this.mainTypeNames.add(typeName)
}

if (!Array.isArray(schemaType.fields)) {
Expand Down Expand Up @@ -469,6 +474,160 @@ class Composer {
return result[fieldSrc.name]
}

/**
* resolve a Query or Mutation field that more than one subgraph publishes, by `onConflict`;
* unset warns and takes the last subgraph's resolver while `mergeTypes` keeps the first
* subgraph's enum values, so a call can validate against one subgraph and run against another
*/
buildSharedFieldResolvers () {
for (const fieldId of Object.keys(this.fields)) {
const typeName = fieldId.slice(0, fieldId.indexOf('.'))
if (!this.mainTypeNames.has(typeName)) { continue }

const subgraphNames = Object.keys(this.fields[fieldId]).filter(s => s !== COMPOSER_SUBGRAPH_NAME)
if (subgraphNames.length < 2) { continue }

const fieldName = fieldId.slice(typeName.length + 1)
const where = `${fieldId} is published by subgraphs ${subgraphNames.join(', ')}`

if (this.onConflict === undefined) {
this.logger.warn({ fieldId, subgraphs: subgraphNames }, `${where}: resolved by the last subgraph, set onConflict to choose how`)
continue
}

if (this.onConflict === 'error') {
throw new Error(`Cannot compose ${where}: set onConflict to "route", "first" or "last" to resolve it`)
}

if (this.onConflict === 'first' || this.onConflict === 'last') {
const winner = this.onConflict === 'first' ? subgraphNames[0] : subgraphNames[subgraphNames.length - 1]
this.logger.warn({ fieldId, subgraphs: subgraphNames, winner }, `${where}: resolved by subgraph ${winner} (onConflict: ${this.onConflict})`)
this.resolvers[typeName][fieldName] = this.fields[fieldId][winner].resolver
continue
}

this.assertSameSignature(fieldId, subgraphNames, where)
const { argName, routes } = this.sharedFieldDiscriminator(fieldId, subgraphNames, where)
this.assertSameDefaults(fieldId, subgraphNames, where, argName)
this.logger.debug({ fieldId, argName, routes: Object.fromEntries(routes) }, 'route shared field by enum argument')

this.resolvers[typeName][fieldName] = this.createRoutedResolver({ typeName, fieldId, argName, routes })
}
}

// a routed call is validated against the merged schema, which carries the first publisher's
// signature, then sent to whichever subgraph owns the value: a valid call must be valid there too
assertSameSignature (fieldId, subgraphNames, where) {
const signatures = new Map()
for (const subgraphName of subgraphNames) {
signatures.set(subgraphName, printFieldSignature(this.fields[fieldId][subgraphName].src))
}

if (new Set(signatures.values()).size > 1) {
const listed = Array.from(signatures, ([s, sig]) => `${s}: ${sig}`).join('; ')
throw new Error(`Cannot compose ${where}: the signatures differ (${listed})`)
}
}

// an argument the client leaves out is sent without a value, so the receiving subgraph applies
// its own default: they must agree. The routing argument is exempt: a subgraph can only default
// it to a value it owns, and a call left to the merged (first) default is routed there
assertSameDefaults (fieldId, subgraphNames, where, routingArgName) {
const defaults = new Map()
for (const subgraphName of subgraphNames) {
const args = (this.fields[fieldId][subgraphName].src.args ?? [])
.filter(arg => arg.name !== routingArgName && arg.defaultValue != null)
.map(arg => `${arg.name} = ${arg.defaultValue}`)
.sort()
defaults.set(subgraphName, `(${args.join(', ')})`)
}

if (new Set(defaults.values()).size > 1) {
const listed = Array.from(defaults, ([s, d]) => `${s}: ${d}`).join('; ')
throw new Error(`Cannot compose ${where}: the argument defaults differ (${listed})`)
}
}

/**
* find the one argument of a shared field that routes it: an enum, declared as `Enum!` or with
* a default value, whose values are declared by exactly one subgraph each
* @returns {{ argName: string, routes: Map<string, string> }} routes: enum value -> subgraph name
*/
sharedFieldDiscriminator (fieldId, subgraphNames, where) {
const candidates = []
const optional = []
// the signatures are identical at this point, so the first subgraph's arguments stand for all
const args = this.fields[fieldId][subgraphNames[0]].src.args ?? []

for (const arg of args) {
const enumName = enumArgTypeName(arg.type)
if (!enumName) { continue }

// a client may omit a nullable argument without a default, and then nothing routes the call
if (arg.type.kind !== 'NON_NULL' && arg.defaultValue == null) {
optional.push(arg.name)
continue
}

const routes = new Map()
let disjoint = true
for (const subgraphName of subgraphNames) {
for (const { name } of this.types[enumName][subgraphName].src.enumValues) {
if (routes.has(name)) {
disjoint = false
break
}
routes.set(name, subgraphName)
}
if (!disjoint) { break }
}

if (disjoint && routes.size > 0) {
candidates.push({ argName: arg.name, enumName, routes })
}
}

if (candidates.length > 1) {
throw new Error(`Cannot compose ${where}: more than one enum argument could route it (${candidates.map(c => c.argName).join(', ')})`)
}

if (candidates.length === 0) {
const hint = optional.length > 0
? `; ${optional.map(name => `"${name}"`).join(', ')} is a nullable enum argument without a default value, declare it as non-null or give it a default to route by it`
: ''
throw new Error(`Cannot compose ${where}: no enum argument with a distinct set of values per subgraph to route by${hint}`)
}

// the merged enum spans every subgraph declaring it, not only the field's publishers: a value
// from elsewhere passes validation with no subgraph to go to
const { argName, enumName, routes } = candidates[0]
const unroutable = []
for (const subgraphName of Object.keys(this.types[enumName])) {
if (subgraphName === COMPOSER_SUBGRAPH_NAME || subgraphNames.includes(subgraphName)) { continue }
for (const { name } of this.types[enumName][subgraphName].src.enumValues) {
if (!routes.has(name)) { unroutable.push(`${name} (${subgraphName})`) }
}
}
if (unroutable.length > 0) {
throw new Error(`Cannot compose ${where}: enum ${enumName} has values declared by subgraphs that do not publish the field, ${unroutable.join(', ')}`)
}

return { argName, routes }
}

createRoutedResolver ({ typeName, fieldId, argName, routes }) {
return async (parent, args, context, info) => {
const value = args?.[argName]
const subgraphName = routes.get(value)
if (!subgraphName) {
throw new Error(`No subgraph resolves ${fieldId} for ${argName}: ${String(value)}`)
}

const fieldSrc = this.fields[fieldId][subgraphName].src
return this.runResolver({ typeName, subgraphName, fieldSrc, parent, args, context, info })
}
}

/**
* collect queries starting from the resolver fn
*/
Expand Down Expand Up @@ -783,6 +942,29 @@ class Composer {
}
}

// the enum type name of an argument declared as `Enum` or `Enum!`; lists and other kinds do not
// route, a call has to land on exactly one subgraph
function enumArgTypeName (type) {
const t = type.kind === 'NON_NULL' ? type.ofType : type
return t.kind === 'ENUM' ? t.name : undefined
}

// an introspection type reference as SDL, for example `[Book!]!`
function printTypeRef (type) {
if (type.kind === 'NON_NULL') { return `${printTypeRef(type.ofType)}!` }
if (type.kind === 'LIST') { return `[${printTypeRef(type.ofType)}]` }
return type.name
}

// a field's return type and argument types as SDL, argument order and defaults aside, to compare
// across subgraphs
function printFieldSignature (field) {
const args = (field.args ?? [])
.map(arg => `${arg.name}: ${printTypeRef(arg.type)}`)
.sort()
return `(${args.join(', ')}): ${printTypeRef(field.type)}`
}

// TODO improve code logic, add unit tests
// key should be selected by parent type, as it is for parent/many
function parentKey (keys, path, type) {
Expand Down
43 changes: 40 additions & 3 deletions lib/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,57 @@ const QUERY_TYPE = 'QUERY'
const MUTATION_TYPE = 'MUTATION'

/**
* merge only entity types
* merge two definitions of the same type from different subgraphs, t1 the merged one so far and
* t2 the next subgraph's, by `onConflict`
* - ENUM: "first" keeps t1's values, "last" takes t2's, "route" and "error" take the union by name
* - OBJECT: the union of the fields by name; a field both declare is t2's under "last", t1's otherwise
* - unset: t1 for an enum; for an object type the field lists concatenated, and graphql-js keeps
* the last of a duplicated name when it builds the schema
* - anything else: t1
* A Query or Mutation field both publish is resolved by `Composer#buildSharedFieldResolvers`.
*/
function mergeTypes (t1, t2) {
function mergeTypes (t1, t2, onConflict) {
if (t1.src.kind === 'ENUM' && Array.isArray(t1.src.enumValues) && Array.isArray(t2.src.enumValues)) {
if (onConflict === 'last') {
t1.src.enumValues = t2.src.enumValues.slice()
} else if (onConflict === 'route' || onConflict === 'error') {
t1.src.enumValues = unionByName(t1.src.enumValues, t2.src.enumValues)
}
return t1
}

if (t1.src.kind !== 'OBJECT' || !Array.isArray(t1.src.fields)) {
return t1
}
t1.src.fields = t1.src.fields.concat(t2.src.fields)
t1.src.fields = onConflict === undefined
? t1.src.fields.concat(t2.src.fields)
: unionByName(t1.src.fields, t2.src.fields ?? [], onConflict === 'last')

// TODO t1.fields = t1.fields.concat(t2.fields)
// TODO fields.resolvers

return t1
}

/**
* union of two lists of named items; an item both lists name is the second's when
* `preferSecond`, the first's otherwise, in the first's position either way
*/
function unionByName (first, second, preferSecond = false) {
const index = new Map(first.map((item, i) => [item.name, i]))
const union = first.slice()
for (const item of second) {
const at = index.get(item.name)
if (at === undefined) {
index.set(item.name, union.length)
union.push(item)
} else if (preferSecond) {
union[at] = item
}
}
return union
}

// return Query or Mutation if type is one of them
// TODO Subscription
function getMainType (schema, type) {
Expand Down
6 changes: 6 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ function collectPlainArgs (args, nodeArguments, info, types) {
return collectVariableArg(nodeArguments, info, types)
}

// graphql-js fills a value the client did not send from the argument's default, so there is
// no node for it; leave it out and the subgraph applies the same default
if (nodeArguments === undefined) {
return
}

if (Array.isArray(args)) {
const queryArgs = []
for (let i = 0; i < args.length; i++) {
Expand Down
7 changes: 7 additions & 0 deletions lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const nullLogger = require('abstract-logging')

const DEFAULT_COMPOSE_ENDPOINT = '/.well-known/graphql-composition'
const DEFAULT_GRAPHQL_ENDPOINT = '/graphql'
const CONFLICT_STRATEGIES = ['first', 'last', 'route', 'error']

function validateArray (value, name, defaultValue) {
if (!value && defaultValue) { return defaultValue }
Expand Down Expand Up @@ -54,6 +55,12 @@ function validateComposerOptions (options) {

validateString(validatedOptions.queryTypeName, 'queryTypeName')
validateString(validatedOptions.mutationTypeName, 'mutationTypeName')

// unset is a valid choice, see Composer#buildSharedFieldResolvers
validatedOptions.onConflict = options.onConflict
if (validatedOptions.onConflict !== undefined && !CONFLICT_STRATEGIES.includes(validatedOptions.onConflict)) {
throw new TypeError(`onConflict must be one of ${CONFLICT_STRATEGIES.map(s => `"${s}"`).join(', ')}`)
}
validatedOptions.onSubgraphError = validateFunction(options.onSubgraphError, 'onSubgraphError', defaultOnError)

validatedOptions.defaultArgsAdapter = validateDefaultArgsAdapterOptions(options.defaultArgsAdapter)
Expand Down
Loading