Skip to content

Add per-operation hooks to GraphQLHandler - #20

Merged
cideM merged 1 commit into
masterfrom
feat/operation-hooks
Sep 17, 2026
Merged

cideM merged 1 commit into
masterfrom
feat/operation-hooks

Conversation

@cideM

@cideM cideM commented Sep 9, 2026 •

Copy link
Copy Markdown

Context

The Content API team has a feature in its GraphQL API that requires both custom directives to work (already fixed) and extensions support in GQL responses (this PR + one in the graphql library fork + one in our GQL gateway). Specifically, we want to forward the extensions.relations key that Content API returns for queries carrying the @capiRelationMap directive.

Problem

Extensions are currently dropped by the combination of the gateway library and our own GQL gateway.

As for the library, GraphQLHandler builds the response payload for each operation inside executeRequest:

result, err := g.Execute(requestContext, plan)
if err != nil {
    setResult(formatErrorsWithCode(result, err, "INTERNAL_SERVER_ERROR"))
    return
}
payload := map[string]interface{}{"data": result}
// ... persistedQuery ...
setResult(payload)

But Execute only returns (data, err), not extensions, and nothing runs between the payload being assembled and it being stored.

Solution

First, the main alternative I considered was implementing this entirely in the GQL gateway. That would require copying the entire handler (GraphQLHandler and its helpers in http.go) from nautilus/gateway into our GQL gateway, in order to own the one step where the payload is assembled from Execute's return value and add the collected extensions there. Everything else in the copy (request parsing, batch handling, status codes, error formatting) would exist only to reach that step and would have to track upstream changes to http.go by hand. The advantage would be that the fork stays closer to upstream, the downside is unexpected code that needs additional maintenance.

I therefore decided to add a minimal configuration option, similar to the per-step execution hooks we already added to the fork previously (c89d5e6):

type PreExecutionStepHook func(ctx *ExecutionContext) error
type PostExecutionStepHook func(ctx *ExecutionContext, queryResult map[string]interface{}) error

func WithPreExecutionHook(hook PreExecutionStepHook) Option
func WithPostExecutionHook(hook PostExecutionStepHook) Option

Those run once per plan step. The new hooks run once per operation, one level up:

  • WithPreOperationHook(func(rc *RequestContext)) runs once per operation, after the RequestContext is built and before planning. It may replace rc.Context. This matters in batch mode, where every operation is given the same r.Context(); a hook that needs per-operation state derives a child context here, and the executor and queryers see it during execution.
  • WithPostOperationHook(func(rc *RequestContext, payload map[string]interface{})) runs as the last step before an operation's payload is stored, on success and on failure. It sees the payload exactly as the gateway would have written it, persistedQuery included, and may add or modify keys.

The library makes no assumptions about what the hooks do. Batch responses are unaffected: hooks run per operation, and each entry in the batch keeps its own payload.

The idea is that we can use these hooks to attach a collector (pre-op; combines extensions based on a merge policy) to each operation and make sure that the extensions keys from downstream hooks aren't dropped (post-op).

@coveralls

coveralls commented Sep 9, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 35236863921

Coverage increased (+0.04%) to 91.288%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 21 of 21 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3122
Covered Lines: 2850
Line Coverage: 91.29%
Coverage Strength: 414.42 hits per line

💛 - Coveralls

Comment thread gateway.go
Comment on lines +38 to +39
preOperationHook PreOperationHook
postOperationHook PostOperationHook

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OlfaKaroui you once implemented pre- and post execution hooks into the gateway as well, didn't you? Was that in the same area or somewhere else?

@greenmato greenmato left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler already exposes pre/post hooks per plan step, but nothing runs
once per operation, and the response payload is assembled entirely inside
executeRequest. Users who need to touch the payload, e.g. to add response
extensions gathered during execution, had to reimplement the handler.

WithPreOperationHook runs after the RequestContext is built and before
planning; it may replace rc.Context, which is how per-operation state
reaches the executor and the queryers even in batch mode, where every
operation otherwise shares the incoming request context.

WithPostOperationHook runs as the last step before the operation's payload
is written, on success and on failure. It sees the payload exactly as the
gateway would have written it, including the gateway's own extensions, and
may add or modify keys. The gateway itself makes no assumptions about what
the hook does with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cideM
cideM force-pushed the feat/operation-hooks branch from e46be37 to d11662a Compare September 17, 2026 14:55
@cideM
cideM merged commit 6de1f4a into master Sep 17, 2026
8 checks passed
@cideM
cideM deleted the feat/operation-hooks branch September 17, 2026 15:00
@cideM

cideM commented Sep 17, 2026

Copy link
Copy Markdown
Author

nautilus#241

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants