Add the complianceportal Go embed target, wire it into CI and release workflows, and document the new build entry point. Signed-off-by: Bryan Frimin <bryan@probo.com>
330 lines
10 KiB
Markdown
330 lines
10 KiB
Markdown
# Go Style
|
|
|
|
## Project and dependencies
|
|
|
|
- HTTP server: `go.gearno.de/kit/httpserver`
|
|
- HTTP client: `go.gearno.de/kit/httpclient`
|
|
- Tracing: OpenTelemetry (`go.opentelemetry.io/otel`)
|
|
- Pointers: Go 1.26 — use `new(expr)` to create pointers to values (e.g. `new(1)`, `new("foo")`, `new(time.Now())`). Use `go.gearno.de/x/ref` only for dereference helpers (`ref.UnrefOrZero`, etc.)
|
|
|
|
## Grouped declarations
|
|
|
|
Use `type ()`, `const ()`, and `var ()` blocks to group related declarations. Use explicit typed values for string enums, not `iota`.
|
|
|
|
```go
|
|
type (
|
|
CreateFooRequest struct {
|
|
Name string
|
|
Active bool
|
|
}
|
|
|
|
UpdateFooRequest struct {
|
|
ID gid.GID
|
|
Name *string
|
|
Active *bool
|
|
}
|
|
)
|
|
|
|
const (
|
|
NameMaxLength = 100
|
|
ContentMaxLength = 5000
|
|
)
|
|
|
|
var (
|
|
_ Reader = (*FileReader)(nil)
|
|
_ Writer = (*FileWriter)(nil)
|
|
)
|
|
```
|
|
|
|
## Multiline parameter and argument lists
|
|
|
|
The same single-line-or-multiline rule applies to **both** function/method **definitions** (parameter lists) and **call expressions** (argument lists). Never mix — if any parameter or argument breaks onto another line, put every one on its own line.
|
|
|
|
### Function and method definitions
|
|
|
|
- **Single-line signature** — the entire `func` line (name, parameters, return types) fits on one source line.
|
|
- **Multiline signature** — if it doesn't fit on one line, each parameter goes on its own indented line with a trailing comma. The closing `)` sits on its own line, followed by the return types.
|
|
|
|
```go
|
|
// Good — fits on one line
|
|
func (s *Service) GetFoo(ctx context.Context, id gid.GID) (*Foo, error) {
|
|
|
|
// Good — multiline: each parameter on its own line
|
|
func (s *Service) CreateFoo(
|
|
ctx context.Context,
|
|
tenantID gid.TenantID,
|
|
req CreateFooRequest,
|
|
) (*Foo, error) {
|
|
|
|
// Bad — mixed: some params on the func line, rest on the next
|
|
func (s *Service) CreateFoo(ctx context.Context, tenantID gid.TenantID,
|
|
req CreateFooRequest) (*Foo, error) {
|
|
|
|
// Bad — closing paren on the last param line
|
|
func (s *Service) CreateFoo(
|
|
ctx context.Context,
|
|
tenantID gid.TenantID,
|
|
req CreateFooRequest) (*Foo, error) {
|
|
```
|
|
|
|
### Call expressions
|
|
|
|
In the [Go spec](https://go.dev/ref/spec#Calls), a **call** is a primary expression `f(a1, a2, … an)` where `f` is the **function value** (or **method value**) and `a1` … `an` are **arguments** passed to the matching parameters.
|
|
|
|
Treat the **argument list** as either single-line or multiline — never mixed:
|
|
|
|
- **Single-line call** — the entire call, from the callee through the closing `)`, fits on one source line. Any argument may be a short expression (including a one-line composite literal or conversion).
|
|
- **Multiline call** — if any argument is written across multiple lines (e.g. a multi-line **composite literal**, **function literal**, or other expression that contains a line break), then **every** argument must start on its own line: one argument per line at the top level of that argument list. The closing `)` is on its own line after the last argument (with a trailing comma after the final argument when the list is multiline).
|
|
|
|
Do not place some arguments on the same line as the opening `(` while others continue on following lines.
|
|
|
|
```go
|
|
// Good — entire call on one line
|
|
id := gid.New(tenantID, "Foo")
|
|
|
|
// Good — multiline argument list; each argument on its own line
|
|
svc, err := foo.NewService(
|
|
ctx,
|
|
db,
|
|
logger,
|
|
foo.Config{
|
|
Interval: 10 * time.Second,
|
|
MaxRetry: 3,
|
|
},
|
|
)
|
|
|
|
// Good — function literal argument is multiline, so the name argument is on its own line too
|
|
t.Run(
|
|
"handoff with custom tool name",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
// ...
|
|
},
|
|
)
|
|
|
|
// Bad — mixed: first arguments on the callee line, last argument is a multiline composite literal
|
|
svc, err := foo.NewService(ctx, db, logger, foo.Config{
|
|
Interval: 10 * time.Second,
|
|
})
|
|
|
|
// Bad — single multiline argument starts on the opening ( line
|
|
body, err := json.Marshal(firecrawlRequest{
|
|
Query: query,
|
|
Limit: maxResults,
|
|
})
|
|
|
|
// Good — single multiline argument: break after (, trailing comma, ) alone
|
|
body, err := json.Marshal(
|
|
firecrawlRequest{
|
|
Query: query,
|
|
Limit: maxResults,
|
|
},
|
|
)
|
|
|
|
// Bad — function literal starts on the opening ( line
|
|
sort.Slice(items, func(i, j int) bool {
|
|
return items[i].Name < items[j].Name
|
|
})
|
|
|
|
// Good — function literal on its own line
|
|
sort.Slice(
|
|
items,
|
|
func(i, j int) bool {
|
|
return items[i].Name < items[j].Name
|
|
},
|
|
)
|
|
```
|
|
|
|
The same rule applies to **method calls** `x.M(a1, …)` — the receiver is already bound; the rule applies to the **argument list** after the method name.
|
|
|
|
## Import ordering
|
|
|
|
Two groups separated by a blank line: stdlib, then everything else (third-party and internal sorted together alphabetically).
|
|
|
|
```go
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"go.gearno.de/kit/httpserver"
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
"go.probo.inc/probo/pkg/probo"
|
|
complianceportal_v1 "go.probo.inc/probo/pkg/server/api/complianceportal/v1"
|
|
)
|
|
```
|
|
|
|
## Receiver names
|
|
|
|
Short receivers: usually single-letter matching the type (`s` for Service, `c` for Client, `p` for Provider).
|
|
|
|
## Error handling
|
|
|
|
Always name error variables `err`. When a function can return errors from multiple call sites, every error must be wrapped so the caller can distinguish them. Wrap with `fmt.Errorf` using lowercase messages starting with `cannot`:
|
|
|
|
```go
|
|
return nil, fmt.Errorf("cannot load trust center: %w", err)
|
|
return nil, fmt.Errorf("cannot create SAML service: %w", err)
|
|
```
|
|
|
|
When multiple errors can come from the same function, each must have a distinct wrap message:
|
|
|
|
```go
|
|
func (s *Service) DoSomething(ctx context.Context) error {
|
|
foo, err := s.loadFoo(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot load foo: %w", err)
|
|
}
|
|
|
|
bar, err := s.loadBar(ctx, foo.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot load bar: %w", err)
|
|
}
|
|
|
|
err = s.save(ctx, bar)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot save bar: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
Sentinel errors in grouped `var ()` blocks. Custom error types implement `Unwrap() error`. Use `errors.Is` for sentinel checks. Use `errors.AsType[T](err)` (generic form) instead of `errors.As(err, &ptr)` for type assertions:
|
|
|
|
```go
|
|
// Good
|
|
if e, ok := errors.AsType[*ValidationError](err); ok {
|
|
// use e
|
|
}
|
|
|
|
// Bad — avoid the two-argument form
|
|
var ve *ValidationError
|
|
if errors.As(err, &ve) {
|
|
// use ve
|
|
}
|
|
```
|
|
|
|
## Naming
|
|
|
|
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
|
|
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
|
|
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
|
|
- Unexported types for internal data: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
|
|
|
|
## Functional options and Config structs
|
|
|
|
Use `Config` structs when a constructor has many required parameters. Use functional options (`With*` functions) for optional configuration.
|
|
|
|
```go
|
|
type Option func(*Bridge)
|
|
|
|
func WithDryRun(dryRun bool) Option {
|
|
return func(s *Bridge) {
|
|
s.dryRun = dryRun
|
|
}
|
|
}
|
|
|
|
func NewBridge(provider provider.Provider, client *scimclient.Client, opts ...Option) *Bridge {
|
|
s := &Bridge{provider: provider, scimClient: client}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
return s
|
|
}
|
|
```
|
|
|
|
## Interfaces
|
|
|
|
Define interfaces in the consumer package. Keep them small. Verify satisfaction at compile time:
|
|
|
|
```go
|
|
var (
|
|
_ unit.Configurable = (*Implm)(nil)
|
|
_ unit.Runnable = (*Implm)(nil)
|
|
)
|
|
```
|
|
|
|
## Context
|
|
|
|
Always first parameter. Private struct keys for context values:
|
|
|
|
```go
|
|
type ctxKey struct{ name string }
|
|
var trustCenterIDKey = &ctxKey{name: "trust_center_id"}
|
|
```
|
|
|
|
## URL and query parameter construction
|
|
|
|
**Never** build URLs with `fmt.Sprintf`, string concatenation, or any form of string formatting. Always use the `net/url` package to construct URLs safely.
|
|
|
|
- Use `url.URL` struct to build full URLs (scheme, host, path, query).
|
|
- Use `url.Values` to build query parameters, then call `.Encode()`.
|
|
- **Always** wrap user-supplied path segments with `url.PathEscape` before passing them to `url.JoinPath`. `url.JoinPath` does **not** percent-encode slashes or reserved characters — a value like `parent/child` silently adds an extra path segment.
|
|
- Use the `pkg/baseurl.URLBuilder` when constructing URLs from configured base URLs.
|
|
|
|
```go
|
|
// Bad — fmt.Sprintf
|
|
endpoint := fmt.Sprintf("https://api.example.com/users/%s?active=%t", userID, active)
|
|
|
|
// Bad — string concatenation
|
|
endpoint := "https://api.example.com/orgs/" + orgID + "/members"
|
|
|
|
// Bad — user-supplied value without PathEscape
|
|
u, err := url.JoinPath("https://api.example.com", "groups", groupID, "members")
|
|
|
|
// Good — url.JoinPath with PathEscape on user-supplied segments
|
|
u, err := url.JoinPath("https://api.example.com", "groups", url.PathEscape(groupID), "members")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot build URL: %w", err)
|
|
}
|
|
|
|
parsed, err := url.Parse(u)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse URL: %w", err)
|
|
}
|
|
|
|
q := parsed.Query()
|
|
q.Set("active", strconv.FormatBool(active))
|
|
parsed.RawQuery = q.Encode()
|
|
|
|
// Good — URLBuilder from pkg/baseurl
|
|
u, err := baseURL.URL("/users", userID).
|
|
Query("active", strconv.FormatBool(active)).
|
|
Build()
|
|
```
|
|
|
|
The same rule applies to query parameters specifically: never concatenate `"?key=" + val + "&other=" + val2`. Always use `url.Values` and assign via `RawQuery`:
|
|
|
|
```go
|
|
// Bad
|
|
raw := baseEndpoint + "?domain=" + domain + "&limit=100"
|
|
|
|
// Good
|
|
u, err := url.Parse(baseEndpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse endpoint: %w", err)
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("domain", domain)
|
|
q.Set("limit", "100")
|
|
u.RawQuery = q.Encode()
|
|
```
|
|
|
|
## Logging
|
|
|
|
`go.gearno.de/kit/log` — named, context-aware structured logging with typed fields. **Never log PII, PHI, or other sensitive data** (e.g. emails, names, passwords, tokens, health records). Log opaque identifiers (IDs, request IDs) instead. See [`contrib/claude/logging.md`](logging.md) for the full guide (allowed/forbidden data, field helpers, wiring patterns).
|
|
|
|
```go
|
|
l.InfoCtx(
|
|
ctx,
|
|
"HTTP request to trust center custom domain, redirecting to HTTPS",
|
|
log.String("domain", domain),
|
|
log.String("path", r.URL.Path),
|
|
log.String("to", httpsURL),
|
|
)
|
|
```
|