luima

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 9 Imported by: 0

README

luima

Go Reference Release CI Go Report Card

Luima connects a gqlgen GraphQL server to Fiber v3 and provides resolver helpers for go-pg. Use it when gqlgen, Fiber, and go-pg are already part of your application and you want one implementation of their integration and error-handling rules.

The problems Luima solves

Problem Luima behavior
gqlgen is a net/http handler, while Fiber uses fasthttp Mounts the handler on Fiber and preserves request deadlines, cancellation, and context values for resolvers
Browser clients use gqlgen's GET, POST, and OPTIONS transports Registers all three methods and lets gqlgen dispatch them; OPTIONS support does not add CORS headers
gqlgen's default presenter returns raw resolver errors Sends explicitly public errors to the client and logs and redacts other resolver errors
CRUD resolvers repeat the same database and GraphQL edge cases Handles missing rows, duplicate keys, non-nil lists, scoped queries, and RETURNING * consistently
pg.Connect does not verify a connection immediately Parses the PostgreSQL URL, opens the pool, and runs a startup query before returning

Luima does not generate your GraphQL schema or resolvers. You own gqlgen.yml, schema.graphqls, graph/resolver.go, and every gqlgen generate run. Luima also does not provide authentication, authorization, CORS policy, schema-level pagination or filtering APIs, dataloaders, file uploads, migrations, or scaffolding. Subscriptions are unsupported: fasthttp does not cancel the request context when a client disconnects, so an abandoned stream has no upper bound once RequestTimeout is disabled — which a subscription requires. See docs/fiber.md.

Security: Luima does not identify callers or restrict which rows they can access. Add authentication middleware before mounting GraphQL and add ownership predicates to database queries. The PostgreSQL role in DATABASE_URL determines database privileges; a privileged role may bypass Row Level Security. See Security and Deployment.

Install

Luima requires Go 1.25. It currently targets Fiber v3, gqlgen v0.17, and go-pg v10.

go get github.com/ulas96/luima
go get -tool github.com/99designs/gqlgen

The second command records the gqlgen CLI in your module so go tool gqlgen generate uses the version selected by your go.mod.

Quickstart

This section builds a minimal development server. The examples/quickstart module contains the same schema and resolvers plus production-oriented Fiber settings, middleware ordering, and graceful shutdown.

1. Create the table
create table if not exists app_users (
  personal_id text primary key,
  name        text not null,
  company     text not null,
  projects    text[] not null default '{}'
);
2. Define the GraphQL schema

Create graph/schema.graphqls:

type User {
  personalId: String!
  name: String!
  company: String!
  projects: [String!]!
}

input UserInput {
  name: String!
  company: String!
  projects: [String!]!
}

type Query {
  users: [User!]!
  user(personalId: String!): User
}

type Mutation {
  createUser(personalId: String!, input: UserInput!): User!
  updateUser(personalId: String!, input: UserInput!): User!
  deleteUser(personalId: String!): Boolean!
}

user is nullable because luima.Get returns (nil, nil) when no row matches. users is non-null because luima.List returns an empty, non-nil slice when the table is empty.

3. Define the database model

Create graph/model/user.go:

package model

type User struct {
    tableName  struct{} `pg:"app_users"`
    PersonalID string   `pg:"personal_id,pk"`
    Name       string   `pg:"name"`
    Company    string   `pg:"company"`
    Projects   []string `pg:"projects,array"`
}

Get, Update, and Delete call go-pg's WherePK, so the pk tag is required. Database columns must use exported Go fields. The array option makes go-pg encode Projects as a PostgreSQL array instead of JSON.

4. Configure and run gqlgen

Create gqlgen.yml, replacing your/module with the module path from your go.mod:

schema:
  - graph/*.graphqls

exec:
  filename: graph/generated/generated.go
  package: generated

model:
  filename: graph/model/models_gen.go
  package: model

resolver:
  layout: follow-schema
  dir: graph
  package: graph
  filename_template: "{name}.resolvers.go"

autobind:
  - "your/module/graph/model"

Create the dependency root in graph/resolver.go:

package graph

import "github.com/go-pg/pg/v10"

type Resolver struct {
    DB *pg.DB
}

Generate the gqlgen code:

go tool gqlgen generate
grep -rn 'not implemented' graph/*.resolvers.go

The grep command must return no matches after the resolver bodies are implemented. gqlgen emits compilable stubs that panic when called, so go build alone does not detect an unfinished resolver. The gqlgen contract explains the generated and hand-written file boundaries.

5. Implement the resolvers

Fill the generated resolver methods:

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    return luima.List[model.User](ctx, r.DB, func(q *orm.Query) *orm.Query {
        return q.Order("personal_id").Limit(100)
    })
}

func (r *queryResolver) User(ctx context.Context, personalID string) (*model.User, error) {
    return luima.Get(ctx, r.DB, &model.User{PersonalID: personalID})
}

func (r *mutationResolver) CreateUser(ctx context.Context, personalID string, input model.UserInput) (*model.User, error) {
    return luima.Create(ctx, r.DB, newUser(personalID, input), "user "+personalID)
}

func (r *mutationResolver) UpdateUser(ctx context.Context, personalID string, input model.UserInput) (*model.User, error) {
    return luima.Update(ctx, r.DB, newUser(personalID, input), "user "+personalID)
}

func (r *mutationResolver) DeleteUser(ctx context.Context, personalID string) (bool, error) {
    return luima.Delete(ctx, r.DB, &model.User{PersonalID: personalID})
}

List does not impose an order or row limit; each list resolver must set both. Update writes every model column unless a query modifier selects specific columns.

Put input-to-model helpers such as newUser in graph/resolver.go, not a generated *.resolvers.go file:

func newUser(personalID string, input model.UserInput) *model.User {
    return &model.User{
        PersonalID: personalID,
        Name:       input.Name,
        Company:    input.Company,
        Projects:   input.Projects,
    }
}
6. Start the server
func main() {
    db, err := luima.Connect(os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    app := luima.New(luima.Config{
        Schema: generated.NewExecutableSchema(generated.Config{
            Resolvers: &graph.Resolver{DB: db},
        }),
    })

    log.Fatal(app.Listen(":8080"))
}

Export the database URL and run the application:

set -a
. ./.env
set +a
go run .

The default playground is at http://localhost:8080/ and the GraphQL endpoint is at http://localhost:8080/graphql.

Adding Luima to an existing Fiber application

Use Mount when the application needs Fiber middleware or routes registered before GraphQL. luima.New mounts GraphQL before it returns, so middleware added afterward does not run for the GraphQL route.

app := fiber.New(fiber.Config{
    ReadTimeout:  10 * time.Second,
    WriteTimeout: 30 * time.Second,
    IdleTimeout:  60 * time.Second,
    BodyLimit:    1 << 20,
})

app.Use(limiter.New())

luima.Mount(app, luima.Config{
    Schema: generated.NewExecutableSchema(generated.Config{
        Resolvers: &graph.Resolver{DB: db},
    }),
    DisablePlayground:    true,
    DisableIntrospection: true,
})

Register authentication, CORS, rate limiting, and other Fiber middleware before Mount. The default 15-second RequestTimeout still applies when using Mount. The working quickstart uses the LUIMA_DEV environment variable to enable the playground and introspection during local development.

Authentication alone does not restrict rows. Apply the caller's identity as an additional query predicate:

func (r *mutationResolver) DeleteUser(ctx context.Context, personalID string) (bool, error) {
    ownerID := callerID(ctx)
    return luima.Delete(ctx, r.DB, &model.User{PersonalID: personalID},
        func(q *orm.Query) *orm.Query {
            return q.Where("owner_id = ?", ownerID)
        })
}

Get, Update, and Delete apply modifiers after WherePK. A row that does not match the ownership predicate is reported as absent. Pass untrusted data as ? parameters; do not use it to construct SQL fragments or identifiers. This is the mechanism that stops the helpers being an IDOR by construction — see SECURITY.md.

API

The root package re-exports the public API from four subpackages. Import a subpackage directly when a package should not depend on the entire runtime.

Package Exports
luima All exports listed below
luima/server Config, New, Mount
luima/crud Get, List, Create, Update, Delete
luima/luimaerr CustomError, PresentError, SQLState
luima/db Connect

luima.Config and server.Config are the same type because the root package uses type aliases. The generic CRUD functions are root-package wrappers with the same signatures and behavior as their crud equivalents.

Runtime
func New(cfg Config) *fiber.App
func Mount(r fiber.Router, cfg Config)

New creates a Fiber application using Config.Fiber, then mounts GraphQL. Mount adds GraphQL to an existing app or route group and ignores Config.Fiber.

Config field Default Behavior
Schema none Required executable schema produced by gqlgen
Endpoint /graphql GraphQL endpoint
Playground / Exact playground path; unrelated paths return 404
DisablePlayground false Set to true outside development
PlaygroundTitle graphql Browser page title
DisableIntrospection false Set to true outside development; this is not authorization
RequestTimeout 15s Resolver deadline; a negative value disables it
QueryCache 1000 Parsed-query cache entries; a negative value disables the cache
ComplexityLimit 1000 Operation complexity limit; a negative value disables it and it does not limit returned rows
MaxDepth 15 Operation nesting depth limit; a negative value disables it. Complexity does not bound depth
ErrorPresenter luima.PresentError Controls which error message reaches the client
HTTPMiddleware nil []func(http.Handler) http.Handler; the first item is outermost
Configure nil func(*handler.Server); runs after Luima configures gqlgen and before mounting
Fiber fiber.Config{} Passed to fiber.New by New; ignored by Mount

Zero means “use the default” for RequestTimeout, QueryCache, and ComplexityLimit. Use a negative value to disable one of them. HTTPMiddleware receives the net/http request and the same context seen by resolvers. See Fiber integration for adaptor behavior, middleware ordering, and CORS details.

With the transports configured by Luima, resolver errors are returned in a GraphQL response with HTTP 200, while parse and validation errors use HTTP 422. In both cases, clients must inspect the response body.

CRUD helpers
func Get[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (*T, error)
func List[T any](ctx context.Context, db orm.DB, opts ...func(*orm.Query) *orm.Query) ([]*T, error)
func Create[T any](ctx context.Context, db orm.DB, model *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)
func Update[T any](ctx context.Context, db orm.DB, model *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)
func Delete[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (bool, error)
Helper Result
Get Selects by primary key; returns (nil, nil) when no row matches
List On success, returns a non-nil slice; modifiers supply filtering, ordering, and limits
Create Inserts with RETURNING *; SQLSTATE 23505 becomes label + " already exists"; an insert the database suppressed returns (nil, nil)
Update Updates by primary key with RETURNING *; no match becomes label + " not found"
Delete Deletes by primary key; returns false when no row matches

All helpers accept orm.DB, which is implemented by *pg.DB, *pg.Conn, and *pg.Tx. Pass a transaction to the same helpers inside RunInTransaction.

Update is a full replacement by default. Restrict it to selected columns when implementing a partial update:

updated, err := luima.Update(ctx, db, user, "user "+user.PersonalID,
    func(q *orm.Query) *orm.Query {
        return q.Column("name", "email")
    })
Error handling
type CustomError struct {
    UserMessage   string
    InternalError error
    Code          string
}

func PresentError(ctx context.Context, err error) *gqlerror.Error
func SQLState(err error) string

PresentError applies these rules:

  • *CustomError, including when wrapped: sends UserMessage to the client.
  • A direct *gqlerror.Error: preserves gqlgen's parse or validation message.
  • Any other error: logs the error and sends internal server error.

Treat CustomError.UserMessage as public data. Do not populate it with err.Error() or another database-derived string. InternalError remains available through errors.Is and errors.As.

Code becomes extensions.code on the wire, and is what clients should branch on — the message is built from caller-supplied text and is not a stable contract. An empty Code emits no extensions object.

Code Sent by
CONFLICT Create, on SQLSTATE 23505
NOT_FOUND Update, when no row matched
INTERNAL_SERVER_ERROR Every redacted error
DEPTH_LIMIT_EXCEEDED MaxDepth
GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, COMPLEXITY_LIMIT_EXCEEDED gqlgen, passed through unchanged

Transport-level failures — a malformed body, an unsupported content type — are written by gqlgen's transport before an executor exists. They never reach PresentError, carry no code, and are not redacted.

SQLState returns a PostgreSQL SQLSTATE from a wrapped go-pg error or an empty string when the chain contains no pg.Error. Common integrity codes are 23505 for a unique violation, 23503 for a foreign-key violation, 23502 for a not-null violation, and 23514 for a check violation.

Database connection
func Connect(url string) (*pg.DB, error)

Connect accepts postgres:// and postgresql:// URLs, creates a go-pg pool, and executes select 1 before returning. It closes the pool if the startup query fails. Use sslmode=verify-full when the server certificate must be verified. See Deployment for supported URL parameters, TLS behavior, environment files, serving over TLS and behind a proxy, and production database settings.

Documentation

Document Contents
The gqlgen contract Generated files, resolver layout, autobinding, and schema checks
Fiber integration Methods, context propagation, middleware behavior, buffering, and CORS
Deployment PostgreSQL URLs, TLS verification, .env in Docker, serving over TLS and behind a proxy, and security posture
Gotchas Known failure modes and their fixes
Quickstart module Complete runnable server

Development

make test        # run tests; the database-backed CRUD test skips without DATABASE_URL
make test-db     # load .env and run the database-backed test
make lint
make example     # build the quickstart and reject unimplemented resolver stubs

go test ./... reports success when TestCRUD is skipped. To exercise the real driver, set DATABASE_URL, run the tests with verbose output, and confirm that TestCRUD passes rather than skips. See Contributing for the complete development workflow.

License

MIT. See LICENSE.

Documentation

Overview

Package luima @notice The boilerplate between gqlgen and Fiber v3, minus the parts you would get wrong.

@dev It is two things: a runtime — Config to a *fiber.App with the gqlgen handler mounted correctly, a Postgres pool, and an error presenter that does not leak your schema — and resolver-body helpers, generic CRUD over go-pg that gets the error classification right.

It is not, and cannot be, a replacement for gqlgen codegen. gqlgen generates code into your module: generated.NewExecutableSchema is a symbol only your own `go tool gqlgen generate` run produces, from your own schema.graphqls. So you still own gqlgen.yml, schema.graphqls, graph/resolver.go and the codegen step — see docs/gqlgen-contract.md, because getting it wrong produces runtime panics that `go build` does not catch.

Out of scope in v1, deliberately: auth, pagination, filtering, dataloaders, subscriptions, file upload, migrations, a scaffolding CLI.

The packages

This package re-exports the four sub-packages so the common case needs one import:

import "github.com/ulas96/luima"

app := luima.New(luima.Config{Schema: …})
return luima.Create(ctx, r.DB, u, "user "+id)

Import them directly when you want a narrower dependency — a package that returns a *CustomError but must not pull in Fiber or gqlgen's handler wants luimaerr alone:

[github.com/ulas96/luima/server]   Config, New, Mount
[github.com/ulas96/luima/crud]     Get, List, Create, Update, Delete
[github.com/ulas96/luima/luimaerr] CustomError, PresentError, SQLState
[github.com/ulas96/luima/db]       Connect

The two spellings are interchangeable, not merely similar: the types below are aliases, so luima.Config and server.Config are the same type and either constructor accepts either literal.

The cost of that convenience, stated plainly: every exported symbol lives at two import paths, and a new sub-package export has to be added to this file by hand or it stays invisible from the root. Aliases carry field and signature changes automatically — only genuinely new symbols can be missed, and there are nine of them.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Connect

func Connect(url string) (*pg.DB, error)

Connect @notice Opens the pool the resolvers query through and proves it works.

@param url a postgres:// or postgresql:// connection string @return *pg.DB a live pool, already proven with a round trip. See [db.Connect]. @return error a parse failure, or the ping failure with the pool already closed

func Create

func Create[T any](ctx context.Context, d orm.DB, m *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)

Create @notice Inserts m and returns the stored row, classifying 23505 as a conflict.

@param ctx the resolver context @param d orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param m the model to insert @param label names the thing in the conflict message @param opts query modifiers, e.g. q.OnConflict("DO NOTHING") @return *T the stored row with RETURNING * applied, or nil if the insert was suppressed. See crud.Create. @return error a *CustomError on 23505, nil on a suppressed insert, the bare driver error otherwise

func Delete

func Delete[T any](ctx context.Context, d orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (bool, error)

Delete @notice Removes the row with key's primary key, reporting whether one was there.

@param ctx the resolver context @param d orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param key a model with only its primary key populated @param opts query modifiers applied left to right, after WherePK; this is where an ownership predicate goes @return bool true when a row was deleted, false when none matched. See crud.Delete. @return error any driver error

func Get

func Get[T any](ctx context.Context, d orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (*T, error)

Get @notice Selects one row by primary key; a missing row is (nil, nil).

@param ctx the resolver context @param d orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param key a model with only its primary key populated @param opts query modifiers applied left to right, after WherePK @return *T the stored row, or nil when no row matched. See crud.Get. @return error any driver error other than pg.ErrNoRows

func List

func List[T any](ctx context.Context, d orm.DB, opts ...func(*orm.Query) *orm.Query) ([]*T, error)

List @notice Selects rows, applying each opt to the query in order.

@param ctx the resolver context @param d orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param opts query modifiers applied left to right; none means "select every row" @return []*T the rows, never nil. See crud.List. @return error any driver error

func Mount

func Mount(r fiber.Router, cfg Config)

Mount @notice Registers the GraphQL routes on a router you already have — an existing app, or a group.

@param r a *fiber.App or the result of app.Group(prefix). See server.Mount. @param cfg the server configuration; only Schema is required

func New

func New(cfg Config) *fiber.App

New @notice Builds a *fiber.App with the GraphQL endpoint and playground mounted.

@param cfg the server configuration; only Schema is required @return *fiber.App an app ready for Listen. See server.New.

func PresentError

func PresentError(ctx context.Context, err error) *gqlerror.Error

PresentError @notice The error contract, and Config.ErrorPresenter's default.

@dev A function rather than a var: a package-level var would let any consumer reassign the error contract for every other consumer in the binary.

@param ctx the resolver context, read only for the GraphQL field path @param err the error a resolver returned @return *gqlerror.Error the message the client receives. See luimaerr.PresentError.

func SQLState

func SQLState(err error) string

SQLState @notice Returns the Postgres SQLSTATE of err, or "" if err is not a driver error.

@param err any error, including nil and wrapped chains @return string the five-character SQLSTATE, or "". See luimaerr.SQLState.

func Update

func Update[T any](ctx context.Context, d orm.DB, m *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)

Update @notice Replaces every column of the row with m's primary key.

@param ctx the resolver context @param d orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param m the complete model, primary key included; every column is written @param label names the thing in the not-found message @param opts query modifiers applied left to right, after WherePK; q.Column(...) narrows the SET clause, q.Where(...) scopes the update to rows the caller owns @return *T the stored row. See crud.Update. @return error a *CustomError when no row matched, the bare driver error otherwise

Types

type Config

type Config = server.Config

Config @notice Assembles the server. See server.Config for the fields and their defaults.

type CustomError

type CustomError = luimaerr.CustomError

CustomError @notice Carries a message the client is allowed to see. See luimaerr.CustomError.

Directories

Path Synopsis
Package crud @notice Five generic helpers for the bodies of go-pg-backed gqlgen resolvers.
Package crud @notice Five generic helpers for the bodies of go-pg-backed gqlgen resolvers.
Package db @notice Opens the Postgres pool luima's resolvers query through.
Package db @notice Opens the Postgres pool luima's resolvers query through.
Package luimaerr @notice luima's error contract: the one place that decides what a resolver error is allowed to tell a client.
Package luimaerr @notice luima's error contract: the one place that decides what a resolver error is allowed to tell a client.
Package server @notice Mounts a gqlgen handler on Fiber v3, correctly.
Package server @notice Mounts a gqlgen handler on Fiber v3, correctly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL