Skip to content
SHC Docs

API Standards

This page is the in-module API cheatsheet. The full design contract lives in ../api-design.md — read that first.

SHC uses go-chi with Unix socket communication between CLI and server. Modules build their own http.Handler and the daemon mounts them at boot via the registry pattern.

// modules/{module}/router.go
package {module}
import (
"net/http"
"github.com/go-chi/chi/v5"
"gitlab.com/bitspur/selfhosted-cloud/ce/core/api"
"gitlab.com/bitspur/selfhosted-cloud/ce/core/registry"
"gitlab.com/bitspur/selfhosted-cloud/ce/core/validation"
)
func buildRouter(_ registry.Deps) http.Handler {
r := chi.NewRouter()
r.Post("/api/method/shc.{module}.create", createHandler())
return r
}

Use JSON-RPC style method names:

OperationPatternExample
Listshc.{module}.listshc.backup.list
Getshc.{module}.getshc.backup.get
Createshc.{module}.createshc.backup.create
Updateshc.{module}.updateshc.backup.update
Deleteshc.{module}.deleteshc.backup.delete

Plain Go structs with json: tags for the wire shape and validate: tags for input rules.

types.go
type BackupCreate struct {
StackName string `json:"stack_name" validate:"required,min=1,max=255"`
Environment string `json:"environment,omitempty"`
IncludeVault bool `json:"include_vault,omitempty"`
}
type BackupResponse struct {
ID string `json:"id"`
StackName string `json:"stack_name"`
CreatedAt time.Time `json:"created_at"`
SizeBytes int64 `json:"size_bytes"`
}

Wire-level envelopes ({"message": ...} and {"data": ...}) come from core/api:

api.WriteJSON(w, api.MethodResponse[BackupResponse]{Message: out})

The chi middleware stack populates a *ctx.Ctx from request headers. Handlers read it via ctx.FromRequest(r).

func createHandler(svc *Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := ctx.FromRequest(r)
var req BackupCreate
if err := validation.DecodeAndValidate(r.Body, &req); err != nil {
api.WriteError(w, err)
return
}
out, err := svc.Create(r.Context(), c, req)
if err != nil {
api.WriteError(w, err)
return
}
api.WriteJSON(w, api.MethodResponse[BackupResponse]{Message: out})
}
}

Coded errors flow through api.WriteError, which type-asserts to *coded.CodedError and emits the canonical {error_code, message, http_status} envelope.

func getStackHandler(svc *Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req GetRequest
if err := validation.DecodeAndValidate(r.Body, &req); err != nil {
api.WriteError(w, err)
return
}
stack, err := svc.Get(r.Context(), req.Name)
if err != nil {
api.WriteError(w, err)
return
}
if stack == nil {
api.WriteError(w, X404908STKNFD(req.Name))
return
}
api.WriteJSON(w, api.MethodResponse[*StackResponse]{Message: stack})
}
}

Every GET /api/resource/<Collection> listing follows one contract (parse the params with api.ParseListPage, page materialised slices with api.PageSlice — see core/api/pagination.go):

  • Query params: limit (page size; 0 = everything, overriding any default) and offset (rows to skip). Entity collections (App, Stack, Node, Tenant, Repo, Usage, Secret, Backup, Worker) default to limit=0; append-only logs default to a server page and may cap explicit limits (Audit 100/1000, JobRun 50/100, Task 50/500).
  • Response envelope: always total_count (rows matching the filter, ignoring paging) and has_more (rows exist beyond this page) — an applied limit is never silent. Cursor-capable logs (Audit, JobRun) additionally accept cursor and return next_cursor; offset is ignored while a cursor is in play.
  • Every listing has a deterministic total order (ORDER BY with an id tie-break, or an in-handler sort) so pages never shuffle.
type ListResponse struct {
Data []Item `json:"data"`
TotalCount int `json:"total_count"`
HasMore bool `json:"has_more"`
NextCursor *string `json:"next_cursor,omitempty"` // cursor-capable logs only
}

Context is passed via headers:

HeaderPurpose
X-SHC-TenantTenant name
X-SHC-StackStack name
X-SHC-EnvironmentEnvironment name
X-SHC-UsernameUnix username (from CLI)
X-Forwarded-ForClient IP (from proxies)