Skip to content

support @oneOf directive #302

Description

@yarinvak

Bug / Feature Request

composeServices from @theguild/federation-composition strips the standard GraphQL @oneOf directive (and other GraphQL-spec directives like @specifiedBy) during composition. Subgraphs that emit valid GraphQL with @oneOf on input types end up with a supergraph SDL where the directive is gone — both the definition and every application — leaving consumers with a plain INPUT_OBJECT that has lost its tagged-union semantics.

Why this matters

@oneOf is part of the official GraphQL spec (RFC merged Oct 2023) and is the canonical way to model tagged-union inputs. It is not user-defined — every spec-conformant GraphQL implementation now supports it natively. Tools that emit GraphQL from other schema languages (e.g. OpenAPI loaders that map oneOf + discriminator to @oneOf input types) rely on the directive surviving composition so that downstream gateways can route discriminated input correctly.

Today, the only way to keep @oneOf alive through composition is to opt-in via Apollo Federation's @composeDirective(name: "@oneOf") mechanism, which itself requires:

  1. Adding a matching directive @oneOf on INPUT_OBJECT definition to the subgraph SDL.
  2. Adding a custom @link(url: "...", import: ["@oneOf"]) to the schema definition.
  3. Adding @composeDirective(name: "@oneOf") to the schema definition.

That's reasonable for genuinely user-defined directives. It is ceremonial overhead for a directive that the GraphQL spec already mandates.

Where it gets stripped

subgraph/state.js ignores any directive application not in composedDirectives:

// subgraph/state.js
Directive(node) {
    if (composedDirectives.has(node.name.value)) {
        // save directive into state
    }
    // else: silently dropped
}

composedDirectives is populated only by compose-directive validation when it sees @composeDirective:

// subgraph/validation/rules/elements/compose-directive.js
context.stateBuilder.directive.setComposed(matchingDirective.name.value);
context.stateBuilder.composedDirectives.add(matchingDirective.name.value);

compose.js hardcodes the supergraph SDL header to only reference federation specs (link, join, tag, inaccessible, policy, requiresScopes, authenticated). There is no slot where directive @oneOf on INPUT_OBJECT would be added.

A grep across the whole package confirms zero references to oneOf. The composer has no concept of standard GraphQL directives beyond Federation's own.

Repro

Minimal subgraph:

schema { query: Query }
type Query { _empty: String }
directive @oneOf on INPUT_OBJECT
input CatVariant @oneOf {
  small: SmallCat
  big: BigCat
}
input SmallCat { weight: Int }
input BigCat { weight: Int, fierce: Boolean }

Run through composeServices. The supergraph SDL contains input CatVariant { ... } with no @oneOf directive applied and no directive @oneOf definition. A gateway that builds an executable schema from that SDL has no way to know CatVariant is a tagged union.

Real-world impact

We hit this with graphql-mesh + @omnigraph/openapi. The OpenAPI loader correctly emits @oneOf on input types derived from oneOf schemas (see getComposerFromJSONSchema.js's "oneOf without sibling properties" branch). The subgraph SDL is fine. But after composeSubgraphs (which delegates to this package), the directive is gone, and Mesh's runtime resolveDataByUnionInputType — which gates wrapper-key unwrapping on getDirective(schema, type, 'oneOf') — never fires. Wrapped input objects leak through to the REST upstream, where they get rejected.

We worked around it with a defensive unwrap shim on the upstream service. Functional, but the shim shouldn't have to exist; the directive marker is already in the subgraph and was deliberately dropped by the composer.

Proposed fix

Auto-allowlist a small set of standard GraphQL directives that are part of the spec, no opt-in required:

  • @oneOf (input tagged unions)
  • @specifiedBy (scalar URL specifications)
  • Possibly @deferrable / others as the spec evolves.

Sketch:

// subgraph/state.js
const SPEC_DIRECTIVES = new Set(['oneOf', 'specifiedBy']);

// inside subgraph state setup:
for (const name of SPEC_DIRECTIVES) {
    composedDirectives.add(name);
    state.directives.setComposed(name);
}

…plus emitting the directive definitions in the supergraph SDL header when they are used (similar to usedTagSpec / usedInaccessibleSpec flags).

Alternative: detect directive @oneOf on INPUT_OBJECT in any subgraph SDL and treat it as auto-composed without requiring @composeDirective. The directive's location set is fixed by the spec, so no merge-conflict logic is needed.

Environment

  • @theguild/federation-composition: latest
  • Composing OpenAPI-derived subgraphs via @graphql-mesh/fusion-composition
  • Node 22

Happy to send a PR for @oneOf + @specifiedBy if the approach sounds reasonable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions