Skip to content

WIP Resolve GraphQL introspection issue (#69) via schema merging #255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion docs/CONTRIBUTING.MD
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,99 @@ go mod tidy

Contributors should close conversations when complete. Reviewers may reopen if needed.

For additional help, see our [FAQ](./FAQ.md) or open a GitHub issue.
For additional help, see our [FAQ](./FAQ.md) or open a GitHub issue.

# Contributing to GraphQL Schema and Resolvers

## Overview

Our GraphQL setup uses a **hybrid approach** to schema management. We have individual GraphQL schemas defined within each module (e.g., `modules/core/`, `modules/warehouse/`), but we also generate a **unified schema** at the top level (`graph/`) primarily to **enable correct GraphQL introspection** for development tools like Postman, Insomnia, or Apollo Sandbox.

**Why this approach?**

* **Introspection:** The previous method of registering module schemas only at runtime prevented standard introspection tools from seeing the complete API schema. This made development and testing difficult.
* **Unified View:** The build-time merge creates `graph/schema.merged.graphql` and generates corresponding Go code (`graph/generated.go`, `graph/models_gen.go`) that represents the entire API surface. This allows tools to introspect correctly.
* **Runtime Execution:** Currently, the server *still* uses the original `app.RegisterGraphSchema()` mechanism at runtime. This means the actual query execution relies on the schemas defined and registered within each module individually. *([Note: This might be refactored in the future to use the unified schema directly at runtime, which would simplify the process]).*

## Current Limitations & Workflow

Because we merge schemas via simple concatenation for the build-time generation step, but modules still need their *own* complete schemas for runtime registration, there's a necessary, slightly awkward workflow involving commenting/uncommenting common definitions:

1. **Duplicate Definitions:** Common scalars (like `scalar Time`, `scalar Int64`) and base types (`type Query`, `type Mutation`, `type Subscription`) should ideally be defined *only once* (e.g., in `modules/core/interfaces/graph/base.graphql`).
2. **Before `go generate`:** To allow the top-level `go generate ./graph/...` to succeed using the merged schema, you **MUST temporarily comment out** any re-definitions of these common scalars/types in other modules' `.graphql` files (e.g., comment out `scalar Time` in `modules/warehouse/interfaces/graph/base.graphql`).
3. **After `go generate`:** You **MUST uncomment** those lines back in the module `.graphql` files. This is because the runtime `app.RegisterGraphSchema()` for that module needs the complete schema definition, including those scalars, to work correctly.

**Yes, this comment/uncomment step is cumbersome and error-prone.** It's a known trade-off of this hybrid approach. Adhering strictly to defining common types only once (See Solution 1 / Best Practice mentioned previously) and refactoring the runtime to use the single generated `ExecutableSchema` would eliminate this step.

## Build Process Summary

Running `go generate ./graph/...` from the project root performs these steps:

## How Resolvers Work

We have two "layers" of resolvers:

1. **Module-Specific Resolvers:**
* Located in `modules/<module_name>/interfaces/graph/*.resolvers.go`.
* These contain the **actual business logic** for fetching data, calling services, etc.
* They are associated with the schemas loaded individually at runtime via `app.RegisterGraphSchema`.
* **Example:** `modules/core/interfaces/graph/users.resolvers.go` implements the logic for the `user` and `users` queries defined in `modules/core/interfaces/graph/users.graphql`.

2. **Unified Top-Level Resolvers:**
* Located in `graph/resolver.go` and `graph/*.resolvers.go`.
* These are generated based on the *merged* schema (`schema.merged.graphql`).
* The main `graph/resolver.go` defines a `Resolver` struct that holds references to the *module-specific* resolvers (or the main `app` instance).
```go
// graph/resolver.go
type Resolver struct {
app application.Application
coreResolver *coregraph.Resolver // From modules/core/interfaces/graph
warehouseResolver *warehousegraph.Resolver // From modules/warehouse/interfaces/graph
// ... other module resolvers
}

func NewResolver(app application.Application) *Resolver {
// ... instantiate module resolvers ...
return &Resolver{ /* ... */ }
}
```
* The implementation files (e.g., `graph/schema.merged.resolvers.go`) primarily **delegate** calls to the appropriate module-specific resolver.
```go
// graph/schema.merged.resolvers.go (Illustrative)
func (r *queryResolver) User(ctx context.Context, id int64) (*User, error) { // Uses graph.User model
// Delegate to the core module's resolver
coreUserResult, err := r.coreResolver.Query().User(ctx, id) // Calls core's User resolver
if err != nil {
return nil, err
}
return (*User)(coreUserResult), nil // Direct cast if structs are identical
}

func (r *queryResolver) WarehousePosition(ctx context.Context, id int64) (*WarehousePosition, error) { // Uses graph.WarehousePosition
// Delegate to the warehouse module's resolver
warehousePosResult, err := r.warehouseResolver.Query().WarehousePosition(ctx, id) // Calls warehouse's resolver
if err != nil {
return nil, err
}
// return warehousemappers.PositionToGraphModel(warehousePosResult), nil // Example using mapper
return (*WarehousePosition)(warehousePosResult), nil // Direct cast if structs are identical
}
```

## How to Add/Modify GraphQL Fields

1. **Define Schema:** Add/modify types, queries, or mutations in the relevant module's `.graphql` file(s) (e.g., `modules/warehouse/interfaces/graph/new_feature.graphql`).
2. **Implement Logic:** Add the corresponding resolver method implementation in the module's `*.resolvers.go` file (e.g., `modules/warehouse/interfaces/graph/new_feature.resolvers.go`). This implementation should contain the actual business logic.
3. **Prepare for Generation:** **Temporarily comment out** duplicate scalar/base type definitions in non-core `.graphql` files.
4. **Generate Unified Code:** Run `go generate ./graph/...` from the project root. This updates/creates:
* `graph/generated.go`
* `graph/models_gen.go`
* Stubs for new resolvers in `graph/*.resolvers.go` (e.g., `graph/schema.merged.resolvers.go`).
5. **Restore Source Schemas:** **Uncomment** the lines commented out in Step 3.
6. **Implement Delegation:** Go to the generated stub in the top-level `graph/*.resolvers.go` file. Implement the method by:
* Getting the correct module resolver instance (e.g., `r.warehouseResolver`).
* Calling the corresponding method you implemented in the module resolver (Step 2).
* Mapping the result to the top-level generated model type if necessary (often a simple type cast `(*graph.MyType)(result)` works if the underlying structs are the same, otherwise use a mapper).
7. **Commit:** Commit changes to the module's `.graphql` and `*.resolvers.go` files, the top-level `graph/*.resolvers.go` file containing the delegation, and all updated generated files in `graph/`.

This workflow allows us to have working introspection while keeping the resolver logic located within the relevant module. Remember the manual comment/uncomment steps before and after generation until the runtime is potentially refactored.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ require (
github.com/gotd/neo v0.1.5 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions graph/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package graph

//go:generate bash ../scripts/merge_graphql_schemas.sh
//go:generate go run github.com/99designs/gqlgen generate
Loading
Loading