Skip to content

Rethink middleware #186

@rmosolgo

Description

@rmosolgo

Middleware was designed with a simple, Rack-like API, but turns out that's a bug, not a feature:

Similar issues affect graphql-ruby. For example, if you use graphql-batch, the actual processing is done "out of bounds", not within next_middleware.call. With DeferredExecution, the problem is even worse: there's no way to detect the actual end of the query, since any deferred fields are executed outside the root node's middleware call.

So, we need a system that:

  • can intercept field resolves
  • can detect the start & end of queries (even when processing is done "out of bounds", eg graphql-batch)
  • supports middleware-local state (eg, timing data)
  • has a user-friendly API

Whatever happens, it will be important to support existing middleware somehow. I imagine that will be simple enough, wrapping an existing middleware to port it to the new API, perhaps

legacy_middleware = CustomMiddleware.new 
new_middleware = LegacyMiddlewareAdapter.new(legacy_middleware)
MySchema.middleware << new_middleware 

Some ideas

Make it like query analyzers

  • before_query returns an initial memo
  • subsequent hooks return a new memo
  • problem: Previous middlewares could affect field resolution by returning a value and not calling next_middleware.call. This implementation requires the return value to be memo, how can you intercept a field call?
MyCustomMiddleware = GraphQL::Middleware.define do 
  before_query -> (memo, env) { 
    { 
      counter: 0
     }
  }

  each_field -> (memo, env) {
    memo[:counter] += 1 
    env[:next_middleware].call 
    memo 
  } 

  after_query -> (memo, env) { 
    puts "Total fields: #{memo[:counter]}"
  }
end 

Make it an event listener

  • Execution strategy would be responsible for triggering events at the right time
  • easy to add new events in the future
  • problem: again, how to modify field resolutions? (I don't want to use exceptions for control flow)
MyCustomMiddleware = GraphQL::Middleware.define do 
  on(:begin_query) { ... } 
  on(:end_query) { ... } 
  on(:begin_field) { ... }
  on(:end_field) { ... }
end 

Extend the current definition

  • Add more hooks to middleware objects (a bit like query analyzer's initial_value etc)
  • problem: we have to keep the long list of random args
  • problem: how to track out-of-bound field duration?
  • problem: when we need more hooks, do we just add more methods? seems ... not elegant.
class MyCustomMiddleware 
  def call(*long_list_of_random_args)
    # ... 
  end 
  def before_query(*more_random_args)
  end 
  def after_query(*more_random_args)
  end 
end 

Make it like Rails instrumentation

In this API, code can be run inside an instrumentation block. Handlers can respond to incoming data in some way.

resolved_value = query.instrument("field.resolve", type_defn, field_defn) do 
  # call the resolve function 
end 

query.instrument("query.begin")
# ... do a bunch of deferred stuff 
query.instrument("query.end")

batch_result = query.instrument("batch.resolve", loader) do 
  # Run the batch
end 

Some questions here:

  • How are handlers identified (names, symbols) ? Is there a nesting structure? (I hope not, sounds hard)
  • Can handlers interfere with the block, or only the arguments to instrument?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions