Skip to content

Handler

Initialization

import (
    "github.com/mikolajgasior/gorestapi"
    svccrud "github.com/mikolajgasior/gocrud/pkg/service"
)

svc := svccrud.New(registry, dbConn, gocrud.DialectPostgres)

handler := gorestapi.New(svc, gorestapi.Options{})

New takes two arguments:

Argument Type Description
svc *service.CRUD An initialised service instance
options gorestapi.Options Configuration for the handler (zero value is valid)

Options

type Options struct {
    CORS       cors.CORS
    Routes     map[string]Route
    UserIDFunc func(r *http.Request) uint64
}
Field Type Description
CORS cors.CORS CORS headers written on every response. Zero value emits no CORS headers.
Routes map[string]Route Per-route configuration keyed by URL path segment. Routes absent from the map use the zero value (all operations enabled, all filters allowed).
UserIDFunc func(*http.Request) uint64 Called on every create and update request to obtain the current user's ID, passed to the service as ModifiedBy. When nil, ModifiedBy is always 0.

Route

Route maps a URL path segment to a service registry key and configures per-operation behavior.

type Route struct {
    RegistryKey    string
    Flags          int64
    AllowedFilters []string

    AllowCreate func(obj interface{}, r *http.Request) error
    AllowUpdate func(obj interface{}, r *http.Request) error
    AllowRead   func(obj interface{}, r *http.Request) error
    AllowDelete func(obj interface{}, r *http.Request) error

    PreCreate func(obj interface{}, r *http.Request) error
    PreUpdate func(obj interface{}, r *http.Request) error

    PostRead     func(obj interface{}, r *http.Request) error
    PostListItem func(obj interface{}, r *http.Request) error

    FilterList func(r *http.Request) FilterSet
    FilterRead func(r *http.Request) FilterSet

    CreateConstructor func() interface{}
    UpdateConstructor func() interface{}
    ReadConstructor   func() interface{}
    ListConstructor   func() interface{}
}

RegistryKey

When RegistryKey is empty the URL path segment is used as the service registry key. Set it explicitly when the URL path and the registry key should differ:

Routes: map[string]gorestapi.Route{
    "v1/users": {RegistryKey: "users"},
}

Flags

Flags is a bitmask that controls which operations and features are disabled for a route. Combine multiple flags with |. The zero value enables everything.

Constant Disables
DisableCreate PUT /{path}/ (create) — responds 405
DisableUpdate PUT /{path}/{id} (update) — responds 405
DisableDelete DELETE /{path}/{id} — responds 405
DisableRead GET /{path}/{id} — responds 405
DisableList GET /{path}/ — responds 405
DisableFilters All filter_val_* / filter_op_* query parameters are ignored

AllowedFilters

AllowedFilters is a whitelist of field names that clients may pass as filters. When empty all fields are allowed (unless DisableFilters is set). Fields not in the list are silently dropped from the request.

Routes: map[string]gorestapi.Route{
    "notes": {AllowedFilters: []string{"UserID", "Status"}},
}

Authorization hooks — Allow*

Each Allow* hook is called with the loaded object and the request. Return a non-nil error to reject the operation with 403 Forbidden.

Field When called Object passed
AllowCreate After the request body is unmarshalled, before saving The new object populated from the request
AllowUpdate After the existing record is loaded, before the request body is applied The stored record (original state)
AllowRead After the record is loaded The loaded record
AllowDelete After the record is loaded, before deletion The loaded record

AllowUpdate receives the record as stored in the database so it reflects the original owner or state — the incoming request values have not been applied yet.

noteOwner := func(obj interface{}, r *http.Request) error {
    note := obj.(*Note)
    headerUserID, _ := strconv.ParseUint(r.Header.Get("X-User-ID"), 10, 64)
    if note.UserID != headerUserID {
        return errors.New("not the note owner")
    }
    return nil
}

Routes: map[string]gorestapi.Route{
    "notes": {
        AllowUpdate: noteOwner,
        AllowDelete: noteOwner,
    },
}

Server-side filters — FilterList / FilterRead

These hooks inject server-controlled filters into queries, merging with (and taking precedence over) any client-supplied filters so clients cannot override them.

FilterList is applied to every list request (GET /{path}/). FilterRead is applied when reading a single record by ID (GET /{path}/{id}); when set, the handler uses List(limit=1) with the ID added as an extra constraint so mismatches return 404 rather than revealing record existence.

type FilterSet struct {
    Vals map[string]string // field → value
    Ops  map[string]string // field → operator (defaults to "eq" when absent)
}
Routes: map[string]gorestapi.Route{
    "notes": {
        FilterList: func(r *http.Request) gorestapi.FilterSet {
            return gorestapi.FilterSet{
                Vals: map[string]string{"UserID": r.Header.Get("X-User-ID")},
            }
        },
        FilterRead: func(r *http.Request) gorestapi.FilterSet {
            return gorestapi.FilterSet{
                Vals: map[string]string{"UserID": r.Header.Get("X-User-ID")},
            }
        },
    },
}

Pre hooks — PreCreate / PreUpdate

Called with the fully-prepared object just before it is written to the database. The request body has already been applied and the ID is set. Use these to stamp server-controlled fields that should not be settable by the client.

Return a non-nil error to abort the operation with 500 Internal Server Error.

Routes: map[string]gorestapi.Route{
    "notes": {
        PreCreate: func(obj interface{}, _ *http.Request) error {
            obj.(*Note).Status = "pending"
            return nil
        },
    },
}

Post hooks — PostRead / PostListItem

Called on loaded objects just before they are serialised to JSON. Use these to set or transform fields in the response that should differ from the stored values.

Field Fires on
PostRead A single-record read (GET /{path}/{id})
PostListItem Each item in a list response (GET /{path}/)

Return a non-nil error to abort with 500 Internal Server Error. For PostListItem an error aborts the entire list response.

Routes: map[string]gorestapi.Route{
    "notes": {
        PostRead: func(obj interface{}, _ *http.Request) error {
            obj.(*Note).Comment = "Returned from API"
            return nil
        },
        PostListItem: func(obj interface{}, _ *http.Request) error {
            obj.(*Note).Comment = "Returned from API"
            return nil
        },
    },
}

Constructor overrides

By default each operation uses the constructor registered for the key in the service. Setting a constructor here overrides that for the specific operation, allowing a different struct type — for example a create-only struct with fewer fields.

Field Type Description
CreateConstructor func() interface{} Constructor for PUT /{path}/ (create)
UpdateConstructor func() interface{} Constructor for PUT /{path}/{id} (update). The existing record is not pre-loaded; the URL id is stamped onto the object after JSON unmarshalling.
ReadConstructor func() interface{} Constructor for GET /{path}/{id}. Only fields present on the override struct are SELECTed.
ListConstructor func() interface{} Constructor for GET /{path}/. Only fields present on the override struct are SELECTed.
// "_" suffix is stripped when deriving the table name:
//   strings.Split("Note_Draft", "_")[0] = "Note" → table "note"
type Note_Draft struct {
    ID      uint64
    Title   string `crud:"req len:1,200"`
    Content string
    UserID  uint64 `crud:"req"`
}

Routes: map[string]gorestapi.Route{
    "notes": {
        CreateConstructor: func() interface{} { return &Note_Draft{} },
    },
}

Mounting

handler.Serve is a standard http.HandlerFunc. Mount it under a prefix using http.StripPrefix so the handler only sees the path-relative URL:

mux := http.NewServeMux()
mux.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(handler.Serve)))
http.ListenAndServe(":8080", mux)

After stripping the mount prefix the handler expects URLs of the form /{path}/{id} where path is a key from Options.Routes and id is an optional numeric record ID.

URL scheme

/{path}/           — list / create
/{path}/{id}       — read / update / delete

path must match a key in Options.Routes. id must be a positive integer or absent.

Password fields

Fields tagged crud:"pass" are never included in responses from the Read and List endpoints. The handler strips them before serialising the object to JSON, regardless of whether the struct field has a json tag or not.

type User struct {
    ID       uint64
    Email    string `crud:"req email"`
    Password string `crud:"pass"` // omitted from GET /users/ and GET /users/{id}
}

Endpoints

List — GET /{path}/

Returns a paginated, filtered list of records.

Query parameters:

Parameter Type Default Description
limit int 10 Maximum number of records to return
offset int 0 Number of records to skip
order string Field name to sort by
order_direction string asc or desc
filter_val_{Field} string Filter value for the named field
filter_op_{Field} string eq Filter operator for the named field

Response 200 OK:

{
  "ok": true,
  "code": "SUCCESS",
  "data": [ { "ID": 1, "Title": "Hello", "..." : "..." } ]
}

Read — GET /{path}/{id}

Returns a single record by ID.

Response 200 OK:

{
  "ok": true,
  "code": "SUCCESS",
  "data": { "ID": 42, "Title": "Hello", "..." : "..." }
}

Response 404 Not Found:

{ "ok": true, "code": "NOT_FOUND" }

Create — PUT /{path}/

Creates a new record. The request body must be a JSON object whose keys match the struct's field names (or json tags when set).

Request body:

{ "Title": "Hello", "Content": "World" }

Response 201 Created:

{
  "ok": true,
  "code": "CREATED",
  "data": { "id": 42 }
}

Response 400 Bad Request (validation failure):

{
  "ok": true,
  "code": "VALIDATION_FAILED",
  "message": "validation failed with violations: ..."
}

Update — PUT /{path}/{id}

Updates an existing record. The request body replaces the record's fields. Returns 404 if the record does not exist.

Response 200 OK:

{ "ok": true, "code": "SUCCESS", "data": { "id": 42 } }

Delete — DELETE /{path}/{id}

Deletes a record by ID.

Response 200 OK:

{ "ok": true, "code": "SUCCESS" }

Response 404 Not Found:

{ "ok": true, "code": "NOT_FOUND" }

Response format

Every response is a JSON object with the following shape:

{
  "ok":      true,
  "code":    "SUCCESS",
  "message": "optional human-readable detail",
  "data":    null
}
Field Type Description
ok bool Always true — use code to distinguish outcomes
code string Machine-readable result code (see below)
message string? Present only on errors; contains a human-readable detail
data any? Present on successful read / list / create / update

Response codes:

Code HTTP status Meaning
SUCCESS 200 Operation completed successfully
CREATED 201 Record created
NOT_FOUND 404 No record with the given ID
VALIDATION_FAILED 400 Request body failed struct validation
BAD_REQUEST 400 Malformed URL or unparseable request body
URL_PATH_ID 400 ID segment in the URL is not a valid number
NOT_ALLOWED 405 Operation is disabled for this route via Flags
FORBIDDEN 403 An Allow* hook rejected the request
SERVICE_ERROR 500 Internal error from the service or a Pre* / Post* hook

CORS

Populate Options.CORS to emit CORS headers on every response:

import (
    "github.com/mikolajgasior/gorestapi"
    "github.com/mikolajgasior/gorestapi/pkg/cors"
)

handler := gorestapi.New(svc, gorestapi.Options{
    CORS: cors.CORS{
        AllowOrigin:  "https://app.example.com",
        AllowHeaders: "Content-Type, Authorization",
        AllowMethods: "GET, PUT, DELETE",
        MaxAge:       3600,
    },
})

Leave CORS as its zero value to emit no CORS headers.