crud

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jan 5, 2025 License: MIT Imports: 14 Imported by: 0

README

Go CRUD Controller

A Go package that provides a generic CRUD controller for REST APIs using Fiber and Bun ORM.

Installation

go get github.com/yourusername/crud-controller

Quick Start

package main

import (
	"time"
	"database/sql"

    "github.com/uptrace/bun/driver/sqliteshim"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/sqlitedialect"
	"github.com/goliatone/go-repository-bun"
	"github.com/google/uuid"
	"github.com/uptrace/bun"
)

type User struct {
	bun.BaseModel `bun:"table:users,alias:cmp"`
	ID            *uuid.UUID `bun:"id,pk,nullzero,type:uuid" json:"id"`
	Name          string     `bun:"name,notnull" json:"name"`
	Email         string     `bun:"email,notnull" json:"email"`
	Password      string     `bun:"password,notnull" json:"password" crud:"-"`
	DeletedAt     *time.Time `bun:"deleted_at,soft_delete,nullzero" json:"deleted_at,omitempty"`
	CreatedAt     *time.Time `bun:"created_at,nullzero,default:current_timestamp" json:"created_at"`
	UpdatedAt     *time.Time `bun:"updated_at,nullzero,default:current_timestamp" json:"updated_at"`
}


func NewUserRepository(db bun.IDB) repository.Repository[*User] {
	handlers := repository.ModelHandlers[*User]{
		NewRecord: func() *User {
			return &User{}
		},
		GetID: func(record *User) uuid.UUID {
			return *record.ID
		},
		SetID: func(record *User, id uuid.UUID) {
			record.ID = &id
		},
		GetIdentifier: func() string {
			return "email"
		},
	}
	return repository.NewRepository[*User](db, handlers)
}

func main() {
	sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared")
	if err != nil {
		panic(err)
	}
	db := bun.NewDB(sqldb, sqlitedialect.New())


	server := fiber.New()
	api := server.Group("/api/v1")
	crud.NewController[*model.User](model.NewUserRepository(db)).RegisterRoutes(api)

	log.Fatal(server.Listen(":3000"))
}

Generated Routes

For a User struct, the following routes are automatically created:

GET    /user/:id      - Get a single user
GET    /users         - List users
POST   /user          - Create a user
PUT    /user/:id      - Update a user
DELETE /user/:id      - Delete a user
Resource Naming Convention

The controller automatically generates resource names following these rules:

  1. If a crud:"resource:name" tag is present, it uses that name:

    type Company struct {
        bun.BaseModel `bun:"table:companies" crud:"resource:organization"`
        // This generates /organization and /organizations endpoints
    }
    
  2. Otherwise, it converts the struct name to kebab-case and handles pluralization:

    • UserProfile becomes /user-profile (singular) and /user-profiles (plural)
    • Company becomes /company and /companies
    • BusinessAddress becomes /business-address and /business-addresses

The package uses proper pluralization rules, handling common irregular cases correctly:

  • Person/person and /people
  • Category/category and /categories
  • Bus/bus and /buses

Configuration

Field Visibility

Use crud:"-" to exclude fields from API responses:

type User struct {
    bun.BaseModel `bun:"table:users"`
    ID       uuid.UUID `bun:"id,pk,notnull" json:"id"`
    Password string    `bun:"password,notnull" json:"-" crud:"-"`
}
Custom Response Handlers

The controller supports custom response handlers to control how data and errors are formatted. Here are some examples:

Default Response Format
// Default responses
GET /users/123
{
    "success": true,
    "data": {
        "id": "...",
        "name": "John Doe",
        "email": "john@example.com"
    }
}

GET /users
{
    "success": true,
    "data": [...],
    "$meta": {
        "count": 10
    }
}

// Error response
{
    "success": false,
    "error": "Record not found"
}
Custom Response Handler Example
// JSONAPI style response handler
type JSONAPIResponseHandler[T any] struct{}

func (h JSONAPIResponseHandler[T]) OnData(c *fiber.Ctx, data T, op CrudOperation) error {
    c.Set("Content-type", "application/vnd.api+json")
    return c.JSON(fiber.Map{
        "data": map[string]interface{}{
            "type":       "users",
            "id":         getId(data),
            "attributes": data,
        },
    })
}

func (h JSONAPIResponseHandler[T]) OnList(c *fiber.Ctx, data []T, op CrudOperation, total int) error {
    items := make([]map[string]interface{}, len(data))
    for i, item := range data {
        items[i] = map[string]interface{}{
            "type":       "users",
            "id":         getId(item),
            "attributes": item,
        }
    }
    c.Set("Content-type", "application/vnd.api+json")
    return c.JSON(fiber.Map{
        "data": items,
        "meta": map[string]interface{}{
            "total": total,
        },
    })
}

func (h JSONAPIResponseHandler[T]) OnError(c *fiber.Ctx, err error, op CrudOperation) error {
    status := fiber.StatusInternalServerError
    if _, isNotFound := err.(*NotFoundError); isNotFound {
        status = fiber.StatusNotFound
    }
    c.Set("Content-type", "application/vnd.api+json")
    return c.Status(status).JSON(fiber.Map{
        "errors": []map[string]interface{}{
            {
                "status": status,
                "title":  "Error",
                "detail": err.Error(),
            },
        },
    })
}

func (h JSONAPIResponseHandler[T]) OnEmpty(c *fiber.Ctx, op CrudOperation) error {
    c.Set("Content-type", "application/vnd.api+json")
    return c.SendStatus(fiber.StatusNoContent)
}

// Using the custom handler
controller := crud.NewController[*User](
    repo,
    crud.WithResponseHandler[*User](JSONAPIResponseHandler[*User]{}),
)

The above handler would produce responses in JSONAPI format:

{
    "data": {
        "type": "users",
        "id": "123",
        "attributes": {
            "name": "John Doe",
            "email": "john@example.com"
        }
    }
}
Query Parameters

The List endpoint supports:

  • Pagination: ?limit=10&offset=20
  • Ordering: ?order=name asc,created_at desc
  • Field selection: ?select=id,name,email
  • Relations: ?include=company,profile
  • Filtering:
    • Basic: ?name=John
    • Operators: ?age__gte=30
    • Multiple values: ?status__in=active,pending

License

MIT

Copyright (c) 2024 goliatone

Documentation

Index

Constants

View Source
const (
	TAG_CRUD         = "crud"
	TAG_BUN          = "bun"
	TAG_JSON         = "json"
	TAG_KEY_RESOURCE = "resource"
)

Variables

View Source
var DefaultLimit = 25
View Source
var DefaultOffset = 0

Functions

func DefaultDeserializer

func DefaultDeserializer[T any](op CrudOperation, ctx Context) (T, error)

DefaultDeserializer provides a generic deserializer.

func DefaultDeserializerMany added in v0.1.0

func DefaultDeserializerMany[T any](op CrudOperation, ctx Context) ([]T, error)

DefaultDeserializerMany provides a generic deserializer.

func DefaultOperatorMap

func DefaultOperatorMap() map[string]string

func GetResourceName

func GetResourceName[T any]() (string, string)

GetResourceName returns the singular and plural resource names for type T. It first checks for a 'crud:"resource:..."' tag on any embedded fields. If found, it uses the specified resource name. Otherwise, it derives the name from the type's name.

func GetResourceTitle added in v0.1.1

func GetResourceTitle[T any]() (string, string)

func SetOperatorMap

func SetOperatorMap(om map[string]string)

Types

type APIListResponse added in v0.0.2

type APIListResponse[T any] struct {
	Success bool     `json:"success"`
	Data    []T      `json:"data"`
	Meta    *Filters `json:"$meta"`
}

type APIResponse added in v0.0.2

type APIResponse[T any] struct {
	Success bool   `json:"success"`
	Data    T      `json:"data,omitempty"`
	Error   string `json:"error,omitempty"`
}

type Context added in v0.1.0

type Context interface {
	Request
	Response
}

type Controller

type Controller[T any] struct {
	Repo repository.Repository[T]
	// contains filtered or unexported fields
}

Controller handles CRUD operations for a given model.

func NewController

func NewController[T any](repo repository.Repository[T], opts ...Option[T]) *Controller[T]

NewController creates a new Controller with functional options.

func (*Controller[T]) Create

func (c *Controller[T]) Create(ctx Context) error

func (*Controller[T]) CreateBatch added in v0.1.0

func (c *Controller[T]) CreateBatch(ctx Context) error

func (*Controller[T]) Delete

func (c *Controller[T]) Delete(ctx Context) error

func (*Controller[T]) DeleteBatch added in v0.1.0

func (c *Controller[T]) DeleteBatch(ctx Context) error

func (*Controller[T]) GetMetadata added in v0.1.1

func (c *Controller[T]) GetMetadata() router.ResourceMetadata

GetMetadata implements router.MetadataProvider, we use it to generate the required info that will be used to create a OpenAPI spec or something similar

func (*Controller[T]) Index added in v0.1.0

func (c *Controller[T]) Index(ctx Context) error

Index supports different query string parameters: GET /users?limit=10&offset=20 GET /users?order=name asc,created_at desc GET /users?select=id,name,email GET /users?include=company,profile GET /users?name__ilike=John&age__gte=30 GET /users?name__and=John,Jack GET /users?name__or=John,Jack

func (*Controller[T]) RegisterRoutes

func (c *Controller[T]) RegisterRoutes(r Router)

func (*Controller[T]) Show added in v0.1.0

func (c *Controller[T]) Show(ctx Context) error

Show supports different query string parameters: GET /user?include=Company,Profile GET /user?select=id,age,email

func (*Controller[T]) Update

func (c *Controller[T]) Update(ctx Context) error

func (*Controller[T]) UpdateBatch added in v0.1.0

func (c *Controller[T]) UpdateBatch(ctx Context) error

type CrudOperation

type CrudOperation string

CrudOperation defines the type for CRUD operations.

const (
	OpCreate      CrudOperation = "create"
	OpCreateBatch CrudOperation = "create:batch"
	OpRead        CrudOperation = "read"
	OpList        CrudOperation = "list"
	OpUpdate      CrudOperation = "update"
	OpUpdateBatch CrudOperation = "update:batch"
	OpDelete      CrudOperation = "delete"
	OpDeleteBatch CrudOperation = "delete:batch"
)

type DefaultResponseHandler added in v0.0.2

type DefaultResponseHandler[T any] struct{}

func (DefaultResponseHandler[T]) OnData added in v0.0.2

func (h DefaultResponseHandler[T]) OnData(c Context, data T, op CrudOperation, filters ...*Filters) error

func (DefaultResponseHandler[T]) OnEmpty added in v0.0.2

func (h DefaultResponseHandler[T]) OnEmpty(c Context, op CrudOperation) error

func (DefaultResponseHandler[T]) OnError added in v0.0.2

func (h DefaultResponseHandler[T]) OnError(c Context, err error, op CrudOperation) error

func (DefaultResponseHandler[T]) OnList added in v0.0.2

func (h DefaultResponseHandler[T]) OnList(c Context, data []T, op CrudOperation, filters *Filters) error

type Filters added in v0.1.0

type Filters struct {
	Operation string         `json:"operation,omitempty"`
	Limit     int            `json:"limit,omitempty"`
	Offset    int            `json:"offset,omitempty"`
	Count     int            `json:"count,omitempty"`
	Order     []Order        `json:"order,omitempty"`
	Fields    []string       `json:"fields,omitempty"`
	Include   []string       `json:"include,omitempty"`
	Relations []RelationInfo `json:"relations,omitempty"`
}

func BuildQueryCriteria added in v0.1.0

func BuildQueryCriteria[T any](ctx Context, op CrudOperation) ([]repository.SelectCriteria, *Filters, error)

Index supports different query string parameters: GET /users?limit=10&offset=20 GET /users?order=name asc,created_at desc GET /users?select=id,name,email GET /users?name__ilike=John&age__gte=30 GET /users?name__and=John,Jack GET /users?name__or=John,Jack GET /users?include=Company,Profile GET /users?include=Profile.status=outdated TODO: Support /projects?include=Message&include=Company

type NotFoundError added in v0.0.2

type NotFoundError struct {
	// contains filtered or unexported fields
}

type Option

type Option[T any] func(*Controller[T])

func WithDeserializer

func WithDeserializer[T any](d func(CrudOperation, Context) (T, error)) Option[T]

WithDeserializer sets a custom deserializer for the Controller.

func WithResponseHandler added in v0.0.2

func WithResponseHandler[T any](handler ResponseHandler[T]) Option[T]

type Order added in v0.1.0

type Order struct {
	Field string `json:"field"`
	Dir   string `json:"dir"`
}

type RelationFilter added in v0.1.0

type RelationFilter struct {
	Field    string `json:"field"`
	Operator string `json:"operator"`
	Value    string `json:"value"`
}

type RelationInfo added in v0.1.0

type RelationInfo struct {
	Name    string           `json:"name"`
	Filters []RelationFilter `json:"filters,omitempty"`
}

type Request added in v0.1.0

type Request interface {
	UserContext() context.Context
	Params(key string, defaultValue ...string) string
	BodyParser(out interface{}) error
	Query(key string, defaultValue ...string) string
	QueryInt(key string, defaultValue ...int) int
	Queries() map[string]string
	Body() []byte
}

type ResourceController added in v0.1.0

type ResourceController[T any] interface {
	RegisterRoutes(r Router)
}

ResourceController defines an interface for registering CRUD routes

type ResourceHandler added in v0.1.0

type ResourceHandler interface {
	// Index fetches all records
	Index(Context) error
	// Show fetches a single record, usually by ID
	Show(Context) error
	Create(Context) error
	CreateBatch(Context) error
	Update(Context) error
	UpdateBatch(Context) error
	Delete(Context) error
	DeleteBatch(Context) error
}

type Response added in v0.1.0

type Response interface {
	Status(status int) Response
	JSON(data interface{}, ctype ...string) error
	SendStatus(status int) error
}

type ResponseHandler added in v0.0.2

type ResponseHandler[T any] interface {
	OnError(ctx Context, err error, op CrudOperation) error
	OnData(ctx Context, data T, op CrudOperation, filters ...*Filters) error
	OnEmpty(ctx Context, op CrudOperation) error
	OnList(ctx Context, data []T, op CrudOperation, filters *Filters) error
}

ResponseHandler defines how controller responses are handled

func NewDefaultResponseHandler added in v0.0.2

func NewDefaultResponseHandler[T any]() ResponseHandler[T]

type Router added in v0.1.0

type Router interface {
	Get(path string, handler func(Context) error) RouterRouteInfo
	Post(path string, handler func(Context) error) RouterRouteInfo
	Put(path string, handler func(Context) error) RouterRouteInfo
	Delete(path string, handler func(Context) error) RouterRouteInfo
}

Router is a simplified interface from the crud package perspective, referencing the generic router

func NewFiberAdapter added in v0.1.0

func NewFiberAdapter(r fiber.Router) Router

NewFiberAdapter creates a new crud.Router that uses a router.Router[any]

func NewGoRouterAdapter added in v0.1.1

func NewGoRouterAdapter[T any](r router.Router[T]) Router

NewGoRouterAdapter creates a new crud.Router that uses a router.Router[T] This follows the same pattern as the existing NewFiberAdapter

type RouterRouteInfo added in v0.1.0

type RouterRouteInfo interface {
	Name(string) RouterRouteInfo
}

RouterRouteInfo is a simplified interface for route info

type ValidationError added in v0.0.2

type ValidationError struct {
	// contains filtered or unexported fields
}

Jump to

Keyboard shortcuts

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