Files
probo/contrib/claude/go-style.md
Émile Ré f0fe70fe1c Restore url.PathEscape on user-supplied path segments in url.JoinPath calls
url.JoinPath does not percent-encode slashes or reserved characters in
its arguments, so user-supplied values (group IDs, slugs, team IDs) must
be wrapped with url.PathEscape to prevent path traversal. Update cursor
rule and contrib guide to codify this as a mandatory practice.

Signed-off-by: Émile Ré <emile@probo.com>
2026-05-20 12:57:26 +04:00

9.9 KiB

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.

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.
// 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, a call is a primary expression f(a1, a2, … an) where f is the function value (or method value) and a1an 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.

// 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).

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"
	"go.probo.inc/probo/pkg/trust"
)

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:

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:

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:

// 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.

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:

var (
	_ unit.Configurable = (*Implm)(nil)
	_ unit.Runnable     = (*Implm)(nil)
)

Context

Always first parameter. Private struct keys for context values:

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.
// 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:

// 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 for the full guide (allowed/forbidden data, field helpers, wiring patterns).

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