luima

package module
v0.2.0 Latest Latest
Warning

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

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

README

luima

Go Reference Release CI Go Report Card

GraphQL servers in Go on Fiber v3 + gqlgen + go-pg.

The pitch is "Apollo Server, in Go". The honest description is: the boilerplate between gqlgen and Fiber, minus the parts you would get wrong.

app := luima.New(luima.Config{
    Schema: generated.NewExecutableSchema(generated.Config{
        Resolvers: &graph.Resolver{DB: db},
    }),
})
log.Fatal(app.Listen(":8080"))
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)
}

That second snippet replaces fifteen lines of errors.As against a driver-specific error interface — and unlike the fifteen, it also has RETURNING *. See the diff.


What this is, and what it cannot be

luima cannot replace gqlgen codegen. gqlgen generates code into your modulegenerated.NewExecutableSchema is a symbol that only your own go tool gqlgen generate run produces, from your own schema.graphqls. No library can produce it, import it, or wrap it ahead of time. Any design where you hand a library a schema file and get a server back is impossible with gqlgen.

So luima is exactly two things:

  1. A runtime. Config → a *fiber.App with the gqlgen handler mounted correctly, a Postgres pool, and an error presenter that does not leak your schema.
  2. Resolver-body helpers. Generic CRUD over go-pg that gets the error classification right — which is the part everybody gets wrong, not the SQL.

You still own gqlgen.yml, schema.graphqls, graph/resolver.go, and the codegen step. docs/gqlgen-contract.md documents that, and it is not optional reading — get it wrong and you ship a server that panics in production and compiles clean.

⚠️ No auth, no RLS

luima provides no authentication. Your server connects as a Postgres role, so if that role is privileged — which the default Supabase connection string is — Row Level Security does not apply to it, and every query runs with full access to every row.

This is the difference between a fine internal service and a public data leak. Put an API gateway, your own Fiber middleware, or a reverse proxy in front of it before it faces the internet. See docs/deployment.md.

Not in scope, deliberately: auth, pagination, filtering, dataloaders, subscriptions, file upload, migrations, a scaffolding CLI. Subscriptions in particular are blocked by the architecture rather than by effort.


Install

go get github.com/ulas96/luima
go get -tool github.com/99designs/gqlgen
module version
github.com/99designs/gqlgen v0.17.94
github.com/go-pg/pg/v10 v10.15.1
github.com/gofiber/fiber/v3 v3.4.0
github.com/vektah/gqlparser/v2 v2.5.36
Go ≥ 1.25 (needs ≥ 1.24 for the tool directive)

Quickstart

A complete working version of everything below is in examples/quickstart — clone and go run ..

1. 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. 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 and users is [User!]!. Both matter: the first is what lets Get return (nil, nil) for a missing row, the second is why List seeds a non-nil slice.

3. graph/model/user.go — hand-written, autobound
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"`
}

A pk tag is mandatory, fields must be exported, and ,array is load-bearing — without it a []string is encoded as JSON, which a text[] column rejects. Why, in detail.

4. gqlgen.yml and graph/resolver.go

Copy both from docs/gqlgen-contract.md. The two settings that will bite you are layout: follow-schema (never single-file) and autobind.

5. Generate, then fill the stubs
go tool gqlgen generate
grep -rn 'not implemented' graph/*.resolvers.go   # must print nothing when you are done

That grep is the schema check — go build is not. gqlgen writes a stub for every schema field with no resolver, and a stub compiles; it panics when called.

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")
    })
}

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})
}

newUser goes in resolver.go, not in schema.resolvers.go — codegen sweeps non-resolver declarations out of that file into a warning comment block.

6. main.go
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"))
}
set -a && . ./.env && set +a && go run .

Playground on http://localhost:8080, API on http://localhost:8080/graphql.

What this replaces

Before — hand-written against gqlgen and go-pg directly:

func (r *mutationResolver) CreateUser(ctx context.Context, personalID string, input model.UserInput) (*model.User, error) {
    u := model.NewUser(personalID, input)
    if _, err := r.DB.ModelContext(ctx, u).Insert(); err != nil {
        var pgErr pg.Error
        if errors.As(err, &pgErr) && pgErr.Field('C') == "23505" {
            return nil, &CustomError{
                UserMessage:   "user " + personalID + " already exists",
                InternalError: err,
            }
        }
        return nil, err
    }
    return u, nil
}

After:

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

API

Full reference on pkg.go.dev, with runnable examples.

Packages

The root package re-exports everything, so the common case is one import. The sub-packages are equivalent — luima.Config is server.Config, an alias rather than a copy — and worth importing directly when you want a narrower dependency.

package contents
luima re-exports all of the below
luima/server Config, New, Mount
luima/crud Get, List, Create, Update, Delete
luima/luimaerr CustomError, PresentError, SQLState
luima/db Connect
Runtime
func New(cfg Config) *fiber.App          // a server
func Mount(r fiber.Router, cfg Config)   // GraphQL on a router you already have
Config field default notes
Schema required; your generated.NewExecutableSchema(...)
Endpoint /graphql
Playground / Fiber matches it exactly — unknown paths 404
DisablePlayground false do this in production
PlaygroundTitle graphql
DisableIntrospection false do this in production; it is not confidentiality — why
RequestTimeout 15s negative disables; zero means unset. The only bound a query gets
QueryCache 1000 negative disables; zero means unset
ComplexityLimit 1000 negative disables; zero means unset. Per field, not per row
ErrorPresenter PresentError
HTTPMiddleware nil func(http.Handler) http.Handler, outermost first — the layer with the real *http.Request, working Set-Cookie, and a context resolvers see
Configure nil func(*handler.Server), run last — the escape hatch to srv.Use, AroundOperations, SetRecoverFunc, SetDisableSuggestion
Fiber fiber.Config{} passed to fiber.New; ignored by Mount. Setting ErrorHandler here does nothing — why

Zero means unset for the three limits, not off, because a zero-valued Config{} has to be the good configuration rather than the pathological one. Turning the query cache off needs a negative number, and there is no good reason to.

RequestTimeout is the one that does security work rather than performance work: nothing else in the stack bounds a query, and go-pg turns the resolver context's deadline into the socket deadline and its cancellation into a Postgres CancelRequest. A zero Config is the good development configuration, not a production one — see SECURITY.md and examples/quickstart/main.go, which is deliberately the production shape.

CRUD
func Get[T any](ctx context.Context, db orm.DB, key *T) (*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, m *T, label string) (*T, error)
func Update[T any](ctx context.Context, db orm.DB, m *T, label string) (*T, error)
func Delete[T any](ctx context.Context, db orm.DB, key *T) (bool, error)

The point of these is the error classification, not the query. Writing db.ModelContext(ctx, u).Insert() was never hard; knowing that a duplicate key must become a *CustomError or it will be redacted to "internal server error" is the part that takes a production incident to learn.

helper classifies
Get pg.ErrNoRows(nil, nil) — a missing row renders as GraphQL null, not an error
List nothing — the non-nil seed is the entire value
Create 23505label + " already exists"
Update no row matched → label + " not found"
Delete nothing — absence is false, not an error

They take orm.DB, so *pg.DB, *pg.Conn and *pg.Tx all satisfy it — pass the tx inside db.RunInTransaction(...) and nothing else changes.

List ships no named Order/Limit wrappers; one closure gets the whole go-pg query API. Do order your lists — Postgres gives no stable row order without ORDER BY, and an unordered List produces intermittently reordered responses that look like a caching bug.

Three things to know: Update is a full replace (no partial updates); its absence signal is not what you expect; and both writes use RETURNING *.

Errors
type CustomError struct {
    UserMessage   string
    InternalError error
}

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

Resolvers opt in to being heard. A resolver returning errors.New("user already exists") produces "internal server error" on the wire. This is the design, not a bug — gqlgen's default presenter would forward raw driver strings, and with them your constraint and column names, to an unauthenticated caller.

PresentError has three branches: *CustomError passes through (you declared it safe), *gqlerror.Error passes through (gqlgen's own text about the query the client just sent — drop this branch and every schema typo reads as "internal server error"), everything else is logged server-side and redacted.

SQLState(err) replaces an errors.As dance whose target type is easy to get wrong: pg.Error is an interface, not a struct pointer, and not pgx's *pgconn.PgError.

code meaning
23505 unique_violation
23503 foreign_key_violation
23502 not_null_violation
23514 check_violation
Talking to this server

Resolver errors return HTTP 200 with the message in the errors array; gqlgen returns 422 for parse and validation errors, so a 422 always means client/schema drift. Either way the body carries the message, so a client must decode the body before it looks at the status.


Documentation

The gqlgen contract What you still own — and why go build is not the schema check
Fiber All vs Post, the unreachable ErrorHandler, and the ceiling that blocks subscriptions
Deployment ParseURL's three parameters, TLS verification, .env in Docker, security posture
Gotchas 26 silent failures, with the fix for each
examples/quickstart The whole thing, running

Development

make test        # no database — TestCRUD skips
make test-db     # sources .env, runs everything
make lint
make example     # build examples/quickstart and check for unfilled stubs

A green go test ./... proves less than it looks. TestCRUD skips silently without DATABASE_URL. Run with -v and confirm the test ran. CI runs it against a postgres:16 service container so it cannot rot.

See CONTRIBUTING.md.


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) (*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 @return *T the stored row, with RETURNING * applied. See crud.Create. @return error a *CustomError on 23505, 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