Lifecycle Hooks๐Ÿ”—

In this section, we'll cover Undine's lifecycle hooks, which allow you to hook into the execution of a GraphQL request.

LifecycleHook๐Ÿ”—

A GraphQL operation is executed in a series of steps. These steps are:

  1. Parsing the GraphQL source document to a GraphQL AST.
  2. Validation of the GraphQL AST against the GraphQL schema.
  3. Execution of the GraphQL operation according to the GraphQL AST.

LifecycleHooks allow you to hook into the these steps. To implement a hook, you need to create a class that inherits from LifecycleHook and implement the the appropriate methods based on the steps you want to hook into. The points you can hook into are:

on_operation / on_operation_async: Encompasses the entire GraphQL operation.

from collections.abc import AsyncGenerator, Generator

from undine.hooks import LifecycleHook


class ExampleHook(LifecycleHook):
    """Example hook"""

    def on_operation(self) -> Generator[None, None, None]:
        print("before")
        yield
        print("after")

    # Async hook uses synchronous version if not implemented.
    async def on_operation_async(self) -> AsyncGenerator[None, None]:
        print("before async")
        yield
        print("after async")

on_parse / on_parse_async: Encompasses the parsing step.

from collections.abc import AsyncGenerator, Generator

from undine.hooks import LifecycleHook


class ExampleHook(LifecycleHook):
    """Example hook"""

    def on_parse(self) -> Generator[None, None, None]:
        print("before")
        yield
        print("after")

    # Async hook uses synchronous version if not implemented.
    async def on_parse_async(self) -> AsyncGenerator[None, None]:
        print("before async")
        yield
        print("after async")

on_validation / on_validation_async: Encompasses the validation step.

from collections.abc import AsyncGenerator, Generator

from undine.hooks import LifecycleHook


class ExampleHook(LifecycleHook):
    """Example hook"""

    def on_validation(self) -> Generator[None, None, None]:
        print("before")
        yield
        print("after")

    # Async hook uses synchronous version if not implemented.
    async def on_validation_async(self) -> AsyncGenerator[None, None]:
        print("before async")
        yield
        print("after async")

on_execution / on_execution_async: Encompasses the execution step.

from collections.abc import AsyncGenerator, Generator

from undine.hooks import LifecycleHook


class ExampleHook(LifecycleHook):
    """Example hook"""

    def on_execution(self) -> Generator[None, None, None]:
        print("before")
        yield
        print("after")

    # Async hook uses synchronous version if not implemented.
    async def on_execution_async(self) -> AsyncGenerator[None, None]:
        print("before async")
        yield
        print("after async")

resolve: Encompasses each field resolver (see graphql-core custom middleware).

from typing import Any

from graphql import GraphQLFieldResolver

from undine import GQLInfo
from undine.hooks import LifecycleHook


class ExampleHook(LifecycleHook):
    """Example hook"""

    # The 'resolve' hook only has a synchronous interface.
    def resolve(self, resolver: GraphQLFieldResolver, root: Any, info: GQLInfo, **kwargs: Any) -> Any:
        print("before")
        result = resolver(root, info, **kwargs)
        print("after")
        return result

Created hooks need to be registered using the LIFECYCLE_HOOKS setting. When there are multiple hooks that run logic on the same step, they will be run in the order they are added in the LIFECYCLE_HOOKS setting list. Specifically, the first hook registered will have its "before" portion run first and its "after" portion run last. You can think of them as a stack of context managers.

LifecycleHookContext๐Ÿ”—

Each hook is passed a LifecycleHookContext object (self.context), which contains information about the current state of the GraphQL request. This includes:

  • source: Source GraphQL document string.
  • document: Parsed GraphQL AST. Available after parsing is complete.
  • variables: Variables passed to the GraphQL operation.
  • operation_name: The name of the GraphQL operation to run from the document. Can be empty if there is only one operation in the document.
  • extensions: GraphQL operation extensions received from the client.
  • request: Django request during which the GraphQL operation is being executed.
  • result: Execution result of the GraphQL operation. Adding a result to this in a LifecycleHook will cause the operation to exit early with the result.
  • lifecycle_hooks: LifecycleHooks in use for this operation.

Examples๐Ÿ”—

Here's some more complex examples of possible lifecycle hooks.

import json
from collections.abc import Generator

from django.core.cache import cache
from graphql import ExecutionResult

from undine.hooks import LifecycleHook


class CachingHook(LifecycleHook):
    """Cache execution results."""

    TIMEOUT = 60

    def on_operation(self) -> Generator[None, None, None]:
        cache_key = f"undine:{self.context.source}:{json.dumps(self.context.variables)}:{self.context.request.user.pk}"
        was_cached = False

        # Check if the result is already cached.
        if cache_key in cache:
            data = cache.get(cache_key)
            was_cached = True

            # Setting results early will cause the hooking point to not run
            # and the graphql execution to exit early with this result.
            self.context.result = ExecutionResult(data=data)

        yield

        # If results where cached, the hooking point will not run, but the
        # hook's "after" portion will. Therefore, don't re-cache the result
        # if it was already cached.
        if was_cached:
            return

        if self.context.result is not None and self.context.result.data is not None:
            cache.set(cache_key, self.context.result.data, timeout=self.TIMEOUT)
from collections.abc import Generator
from time import perf_counter_ns
from typing import Any

from graphql import GraphQLFieldResolver

from undine import GQLInfo
from undine.hooks import LifecycleHook, LifecycleHookContext


class TimingHook(LifecycleHook):
    """Time the execution of each step of the GraphQL operation."""

    def __init__(self, context: LifecycleHookContext) -> None:
        super().__init__(context)

        self.parse_timing: float | None = None
        self.validation_timing: float | None = None
        self.execution_timing: float | None = None
        self.resolver_timings: dict[str, float] = {}

    def on_operation(self) -> Generator[None, None, None]:
        start = perf_counter_ns()
        try:
            yield
        finally:
            end = perf_counter_ns()

            timings = {
                "operation": end - start,
                "parse": self.parse_timing,
                "validation": self.validation_timing,
                "execution": self.execution_timing,
                "resolvers": self.resolver_timings,
            }

            if self.context.result is not None:
                self.context.result.extensions["timings"] = timings

    def on_parse(self) -> Generator[None, None, None]:
        start = perf_counter_ns()
        try:
            yield
        finally:
            self.parse_timing = perf_counter_ns() - start

    def on_validation(self) -> Generator[None, None, None]:
        start = perf_counter_ns()
        try:
            yield
        finally:
            self.validation_timing = perf_counter_ns() - start

    def on_execution(self) -> Generator[None, None, None]:
        start = perf_counter_ns()
        try:
            yield
        finally:
            self.execution_timing = perf_counter_ns() - start

    def resolve(self, resolver: GraphQLFieldResolver, root: Any, info: GQLInfo, **kwargs: Any) -> Any:
        start = perf_counter_ns()
        try:
            return resolver(root, info, **kwargs)
        finally:
            key = ".".join(str(key) for key in info.path.as_list())
            self.resolver_timings[key] = perf_counter_ns() - start