Lacinia GraphQL Engine

repository·master·Indexed 23 days ago

https://github.com/walmartlabs/lacinia

A backend-agnostic GraphQL query execution engine implemented in Clojure. Lacinia provides a full implementation of the Facebook GraphQL specification, allowing developers to define schemas using EDN or SDL, implement field resolvers, and execute queries against data. It supports custom scalars with parse and serialize functions, standard GraphQL directives like @skip and @include, and schema deprecation metadata.

Tokens
23.2K
Snippets
28
Records
157
Agent score
83%

What's inside Lacinia

  1. What is Lacinia?

    master
    Lacinia is a library for implementing the Facebook GraphQL specification in idiomatic Clojure. It allows clients to efficiently obtain exactly the data they need with fewer round-trips to the server. Lacinia is designed to be fully compliant with the GraphQL specification and aims for feature parity with the official graphql-js implementation. It can be integrated into any Clojure HTTP pipeline.
  2. Explore Lacinia sample projects

    master

    Lacinia has been used in various real-world implementations and tutorials. You can study these projects to understand how to integrate Lacinia with different backends, databases, and frontend frameworks:

    • GraphQL Proxies & Backends:
      • boardgamegeek-graphql-proxy: A proxy exposing BoardGameGeek data as GraphQL.
      • Lacinia LDAP backend: A library for querying LDAP/Active Directory via GraphQL.
      • Lacinia Qliksense backend: A library for querying Qliksense servers via GraphQL.
    • API & Database Integrations:
      • leaderboard-api: A game/high-score API built with Compojure and PostgreSQL.
      • Hacker News GraphQL: A Hacker News implementation using Datomic on the backend and re-frame on the frontend.
    • Advanced Architectures & Tutorials:
      • Event sourcing tutorial: A bank simulation using Kafka for queries, mutations, and subscriptions, with a re-graph frontend.
      • Fullstack Learning Project: A Clojure/Lacinia port of 'The Fullstack Tutorial for GraphQL'.
  3. What is the difference between inject-resolvers and attach-resolvers?

    master

    Lacinia provides two ways to attach resolvers to a schema map:

    1. util/inject-resolvers: The preferred and standard approach. It is a concise way to match fields to resolvers by providing a map of namespaces and local names.
    2. util/attach-resolvers: An older approach that is still supported but is considered more cumbersome than inject-resolvers.

    Use util/inject-resolvers for all new development.

  4. How the mutable database component is structured

    master

    In this architecture, the database is encapsulated as a component that manages an in-memory immutable map stored inside a Clojure Atom. This component is decoupled from the schema and server, following a dependency chain: :server -> :schema-provider -> :db.

    The :db component is defined as a record with a constructor function. Its lifecycle is managed via a start method, which initializes the :data Atom. This abstraction allows the underlying storage to be swapped from an in-memory Atom to an external database in the future without changing the function signatures used by the rest of the application.

  5. Implement the FieldResolver protocol

    master

    While field resolvers are typically simple functions accepting context, args, and value, large-scale systems can use the FieldResolver protocol to structure resolvers as components.

    To implement this protocol, define a class or component that provides a single method: resolve-value. This method acts as the analog to a standard field resolver function.

    Supported return types for resolve-value include:

    1. The value itself (direct resolution).
    2. A ResolverResult object.
  6. Distinguish between resolver errors and execution errors

    master

    Lacinia distinguishes between errors explicitly returned by your code and errors caused by the GraphQL engine during parsing or argument application:

    1. Resolver Errors: These are errors you trigger manually using resolve-as. The result map will contain both :data (often nil) and :errors.
    2. Execution/Argument Errors: These occur when the query is malformed, a non-nullable argument is missing, or an argument type is incompatible (e.g., passing a String where an Int is expected). In these cases, the result map will contain only the :errors key, and the :data key will be missing entirely.
  7. Extend entities from other services using @extends

    master

    In a federated architecture, a service can extend an entity that is owned by another service. To do this, use the @extends directive on the entity definition. This indicates that the entity in the current service is a 'stub' for the full entity residing in the source service.

    When extending an entity, you must satisfy these requirements:

    1. Matching Keys: The extended entity must include the exact same @key directive(s) and primary key fields used by the original service. For example, if the original service uses id as the primary key, your stub must also include id.
    2. Field Ownership: Use the @external directive on fields that are owned by the original service to indicate they are provided by another service.
    3. New Fields: You can add new fields to the extended entity (e.g., favoriteProducts on a User entity). These new fields require their own resolvers within the extending service.
  8. Timing and behavior of the source stream callback

    master

    When working with the source stream callback, keep the following timing constraints in mind:

    • Immediate Return: The callback must return nil immediately. It should not block.
    • Asynchronous Processing: The value passed to the callback is used to generate a GraphQL result map. This generation typically happens asynchronously on a different thread.
    • Asynchronous Cleanup: When a subscription is closed (by the client or the streamer), the cleanup callback is invoked asynchronously.
    • Value Types: The value passed to the callback is normally a plain, non-nil value. It may be a wrapped value (e.g., via resolve/with-error) or, in some historical cases, a ResolverResult. If using ResolverResult, you must extract the resolved value before passing it to execute-query.
  9. How the Streamer works in Lacinia subscriptions

    master

    A Streamer is a component responsible for initiating and managing a source stream of values for a subscription. It is defined in a subscription schema using the :stream key.

    Streamers operate in parallel with field resolvers. While field resolvers handle individual field data, the streamer manages the continuous flow of data. To use streamers, you must use the util/inject-streamers function to replace schema keywords with actual implementation functions.

    Streamer Function Signature A streamer function receives three arguments:

    1. Application Context: The standard execution context.
    2. Field Arguments: The arguments provided in the GraphQL subscription query.
    3. Source Stream Callback: A function used to push new values into the stream.

    Lifecycle and Cleanup

    • Setup: The streamer performs operations to start the stream (e.g., subscribing to a Pub/Sub system).
    • Streaming: As new data arrives, the streamer calls the Source Stream Callback with the new value.
    • Termination: The streamer must return a cleanup function. This function is invoked when the subscription is terminated (either by the client closing the connection or by passing nil to the callback).
    • Termination Trigger: Passing nil to the source stream callback signals the end of the stream and triggers the cleanup callback.
  10. Implement interfaces in object definitions

    master

    Objects can implement zero or more interfaces using the :implements key, which takes a list of keywords.

    Constraints:

    • No Inheritance: Objects cannot inherit from other objects; they can only implement interfaces.
    • Field Completeness: An object must include all fields defined in its implemented interfaces. If a field is missing, an exception will be thrown during schema compilation.
    • Type Specificity: When overriding a field from an interface, the field type in the object must be either the exact interface type or an object that implements that interface. This allows for more specific typing (e.g., an interface defines a field as :Character, but an implementing object :Human can define that same field as a list of :Humans).
  11. How operation root objects and operations are defined

    master

    Lacinia handles operations through two primary mechanisms:

    1. Direct Fields on Root Objects: If you define objects named Query, Mutation, or Subscription (or your custom names via :roots), any fields attached to them are automatically treated as available operations.
    2. Schema Maps: Operations can also be defined via the :queries, :mutations, and :subscriptions maps in the input schema. These are merged into the corresponding root object.

    Important Constraints:

    • Merging: While using the :queries, :mutations, and :subscriptions maps is supported, it is not the preferred method compared to defining fields directly on the root objects.
    • Name Collisions: You cannot have a name collision. If an operation defined in a map (e.g., :queries) conflicts with an existing field on the corresponding root object, a schema compile exception is thrown.